item.py 51.7 KB
Newer Older
1
"""Views for items (modules)."""
2
from __future__ import absolute_import
3

4
import hashlib
Julian Arni committed
5
import logging
6
from collections import OrderedDict
7
from datetime import datetime
8
from functools import partial
9
from uuid import uuid4
Steve Strassmann committed
10

11
import dogstats_wrapper as dog_stats_api
12
from django.conf import settings
13
from django.contrib.auth.decorators import login_required
14 15
from django.contrib.auth.models import User
from django.core.exceptions import PermissionDenied
16
from django.http import HttpResponseBadRequest, HttpResponse, Http404
17
from django.utils.translation import ugettext as _
18
from django.views.decorators.http import require_http_methods
19 20 21
from opaque_keys.edx.keys import CourseKey
from opaque_keys.edx.locator import LibraryUsageLocator
from pytz import UTC
22
from xblock.fields import Scope
23
from xblock.fragment import Fragment
Steve Strassmann committed
24

25
from cms.lib.xblock.authoring_mixin import VISIBILITY_VIEW
26 27 28 29 30
from contentstore.utils import (
    find_release_date_source, find_staff_lock_source, is_currently_visible_to_students,
    ancestor_has_staff_lock, has_children_visible_to_specific_content_groups,
    get_user_partition_info,
)
31
from contentstore.views.helpers import is_unit, xblock_studio_url, xblock_primary_child_category, \
32
    xblock_type_display_name, get_parent_xblock, create_xblock, usage_key_with_run
33
from contentstore.views.preview import get_preview_fragment
34 35
from contentstore.utils import is_self_paced

36
from openedx.core.lib.gating import api as gating_api
37
from edxmako.shortcuts import render_to_string
Don Mitchell committed
38
from models.settings.course_grading import CourseGradingModel
39 40 41 42 43 44 45 46 47 48 49 50 51 52
from openedx.core.lib.xblock_utils import wrap_xblock, request_token
from static_replace import replace_static_urls
from student.auth import has_studio_write_access, has_studio_read_access
from util.date_utils import get_default_time_display
from util.json_request import expect_json, JsonResponse
from util.milestones_helpers import is_entrance_exams_enabled
from xmodule.course_module import DEFAULT_START_DATE
from xmodule.modulestore import ModuleStoreEnum, EdxJSONEncoder
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.draft_and_published import DIRECT_ONLY_CATEGORIES
from xmodule.modulestore.exceptions import ItemNotFoundError, InvalidLocationError
from xmodule.modulestore.inheritance import own_metadata
from xmodule.tabs import CourseTabList
from xmodule.x_module import PREVIEW_VIEWS, STUDIO_VIEW, STUDENT_VIEW, DEPRECATION_VSCOMPAT_EVENT
Steve Strassmann committed
53

54 55 56
__all__ = [
    'orphan_handler', 'xblock_handler', 'xblock_view_handler', 'xblock_outline_handler', 'xblock_container_handler'
]
Steve Strassmann committed
57

58 59
log = logging.getLogger(__name__)

60 61
CREATE_IF_NOT_FOUND = ['course_info']

62 63 64 65
# Useful constants for defining predicates
NEVER = lambda x: False
ALWAYS = lambda x: True

66

67 68
def hash_resource(resource):
    """
69
    Hash a :class:`xblock.fragment.FragmentResource`.
70 71
    """
    md5 = hashlib.md5()
72
    md5.update(repr(resource))
73 74 75
    return md5.hexdigest()


76 77 78 79 80 81
def _filter_entrance_exam_grader(graders):
    """
    If the entrance exams feature is enabled we need to hide away the grader from
    views/controls like the 'Grade as' dropdown that allows a course author to select
    the grader type for a given section of a course
    """
82
    if is_entrance_exams_enabled():
83 84 85 86
        graders = [grader for grader in graders if grader.get('type') != u'Entrance Exam']
    return graders


cahrens committed
87
@require_http_methods(("DELETE", "GET", "PUT", "POST", "PATCH"))
88 89
@login_required
@expect_json
90
def xblock_handler(request, usage_key_string):
91 92 93 94
    """
    The restful handler for xblock requests.

    DELETE
95
        json: delete this xblock instance from the course.
96 97
    GET
        json: returns representation of the xblock (locator id, data, and metadata).
98
              if ?fields=graderType, it returns the graderType for the unit instead of the above.
99
        html: returns HTML for rendering the xblock (which includes both the "preview" view and the "editor" view)
100
    PUT or POST or PATCH
Don Mitchell committed
101
        json: if xblock locator is specified, update the xblock instance. The json payload can contain
102 103
              these fields, all optional:
                :data: the new value for the data.
104
                :children: the unicode representation of the UsageKeys of children for this xblock.
105 106 107
                :metadata: new values for the metadata fields. Any whose values are None will be deleted not set
                       to None! Absent ones will be left alone.
                :nullout: which metadata fields to set to None
Don Mitchell committed
108
                :graderType: change how this unit is graded
109 110 111 112
                :isPrereq: Set this xblock as a prerequisite which can be used to limit access to other xblocks
                :prereqUsageKey: Use the xblock identified by this usage key to limit access to this xblock
                :prereqMinScore: The minimum score that needs to be achieved on the prerequisite xblock
                        identifed by prereqUsageKey
113 114 115 116 117 118
                :publish: can be:
                  'make_public': publish the content
                  'republish': publish this item *only* if it was previously published
                  'discard_changes' - reverts to the last published version
                Note: If 'discard_changes', the other fields will not be used; that is, it is not possible
                to update and discard changes in a single operation.
119 120
              The JSON representation on the updated xblock (minus children) is returned.

121
              if usage_key_string is not specified, create a new xblock instance, either by duplicating
122
              an existing xblock, or creating an entirely new one. The json playload can contain
123
              these fields:
124 125 126
                :parent_locator: parent for new xblock, required for both duplicate and create new instance
                :duplicate_source_locator: if present, use this as the source for creating a duplicate copy
                :category: type of xblock, required if duplicate_source_locator is not present.
127
                :display_name: name for new xblock, optional
128 129
                :boilerplate: template name for populating fields, optional and only used
                     if duplicate_source_locator is not present
130
              The locator (unicode representation of a UsageKey) for the created xblock (minus children) is returned.
131
    """
132
    if usage_key_string:
133
        usage_key = usage_key_with_run(usage_key_string)
134

135 136
        access_check = has_studio_read_access if request.method == 'GET' else has_studio_write_access
        if not access_check(request.user, usage_key.course_key):
137
            raise PermissionDenied()
138

139
        if request.method == 'GET':
140 141
            accept_header = request.META.get('HTTP_ACCEPT', 'application/json')

Calen Pennington committed
142
            if 'application/json' in accept_header:
143
                fields = request.GET.get('fields', '').split(',')
144 145
                if 'graderType' in fields:
                    # right now can't combine output of this w/ output of _get_module_info, but worthy goal
146
                    return JsonResponse(CourseGradingModel.get_section_grader_type(usage_key))
147
                # TODO: pass fields to _get_module_info and only return those
jsa committed
148
                with modulestore().bulk_operations(usage_key.course_key):
149 150
                    response = _get_module_info(_get_xblock(usage_key, request.user))
                return JsonResponse(response)
151 152 153
            else:
                return HttpResponse(status=406)

154
        elif request.method == 'DELETE':
155 156
            _delete_item(usage_key, request.user)
            return JsonResponse()
157
        else:  # Since we have a usage_key, we are updating an existing xblock.
158
            return _save_xblock(
159
                request.user,
160
                _get_xblock(usage_key, request.user),
161
                data=request.json.get('data'),
162
                children_strings=request.json.get('children'),
163
                metadata=request.json.get('metadata'),
Don Mitchell committed
164
                nullout=request.json.get('nullout'),
165
                grader_type=request.json.get('graderType'),
166 167 168
                is_prereq=request.json.get('isPrereq'),
                prereq_usage_key=request.json.get('prereqUsageKey'),
                prereq_min_score=request.json.get('prereqMinScore'),
169
                publish=request.json.get('publish'),
170 171
            )
    elif request.method in ('PUT', 'POST'):
172
        if 'duplicate_source_locator' in request.json:
173 174
            parent_usage_key = usage_key_with_run(request.json['parent_locator'])
            duplicate_source_usage_key = usage_key_with_run(request.json['duplicate_source_locator'])
175

176 177
            source_course = duplicate_source_usage_key.course_key
            dest_course = parent_usage_key.course_key
E. Kolpakov committed
178 179 180 181
            if (
                    not has_studio_write_access(request.user, dest_course) or
                    not has_studio_read_access(request.user, source_course)
            ):
182 183
                raise PermissionDenied()

184 185 186
            dest_usage_key = _duplicate_item(
                parent_usage_key,
                duplicate_source_usage_key,
187
                request.user,
188
                request.json.get('display_name'),
189
            )
190

191
            return JsonResponse({"locator": unicode(dest_usage_key), "courseKey": unicode(dest_usage_key.course_key)})
192 193
        else:
            return _create_item(request)
194 195
    else:
        return HttpResponseBadRequest(
196
            "Only instance creation is supported without a usage key.",
197 198
            content_type="text/plain"
        )
Calen Pennington committed
199

200

Calen Pennington committed
201 202 203
@require_http_methods(("GET"))
@login_required
@expect_json
204
def xblock_view_handler(request, usage_key_string, view_name):
Calen Pennington committed
205 206 207 208 209 210 211 212
    """
    The restful handler for requests for rendered xblock views.

    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
    """
213
    usage_key = usage_key_with_run(usage_key_string)
214
    if not has_studio_read_access(request.user, usage_key.course_key):
Calen Pennington committed
215 216 217 218
        raise PermissionDenied()

    accept_header = request.META.get('HTTP_ACCEPT', 'application/json')

219
    if 'application/json' in accept_header:
220
        store = modulestore()
Calen Pennington committed
221
        xblock = store.get_item(usage_key)
222
        container_views = ['container_preview', 'reorderable_container_child_preview', 'container_child_preview']
Calen Pennington committed
223 224 225

        # wrap the generated fragment in the xmodule_editor div so that the javascript
        # can bind to it correctly
226 227 228 229 230 231
        xblock.runtime.wrappers.append(partial(
            wrap_xblock,
            'StudioRuntime',
            usage_id_serializer=unicode,
            request_token=request_token(request),
        ))
Calen Pennington committed
232

233
        if view_name in (STUDIO_VIEW, VISIBILITY_VIEW):
Calen Pennington committed
234
            try:
235
                fragment = xblock.render(view_name)
Calen Pennington committed
236 237 238
            # catch exceptions indiscriminately, since after this point they escape the
            # dungeon and surface as uneditable, unsaveable, and undeletable
            # component-goblins.
239
            except Exception as exc:                          # pylint: disable=broad-except
240
                log.debug("Unable to render %s for %r", view_name, xblock, exc_info=True)
Calen Pennington committed
241 242
                fragment = Fragment(render_to_string('html_error.html', {'message': str(exc)}))

243
        elif view_name in PREVIEW_VIEWS + container_views:
244
            is_pages_view = view_name == STUDENT_VIEW   # Only the "Pages" view uses student view in Studio
245
            can_edit = has_studio_write_access(request.user, usage_key.course_key)
246 247 248 249 250 251

            # Determine the items to be shown as reorderable. Note that the view
            # 'reorderable_container_child_preview' is only rendered for xblocks that
            # are being shown in a reorderable container, so the xblock is automatically
            # added to the list.
            reorderable_items = set()
252
            if view_name == 'reorderable_container_child_preview':
253
                reorderable_items.add(xblock.location)
254

255 256
            paging = None
            try:
257
                if request.GET.get('enable_paging', 'false') == 'true':
258
                    paging = {
259 260
                        'page_number': int(request.GET.get('page_number', 0)),
                        'page_size': int(request.GET.get('page_size', 0)),
261 262
                    }
            except ValueError:
263 264
                return HttpResponse(
                    content="Couldn't parse paging parameters: enable_paging: "
265
                            "{0}, page_number: {1}, page_size: {2}".format(
266 267 268
                                request.GET.get('enable_paging', 'false'),
                                request.GET.get('page_number', 0),
                                request.GET.get('page_size', 0)
269 270 271
                            ),
                    status=400,
                    content_type="text/plain",
272 273
                )

274
            force_render = request.GET.get('force_render', None)
275

276
            # Set up the context to be passed to each XBlock's render method.
277
            context = {
278 279
                'is_pages_view': is_pages_view,     # This setting disables the recursive wrapping of xblocks
                'is_unit_page': is_unit(xblock),
280
                'can_edit': can_edit,
281
                'root_xblock': xblock if (view_name == 'container_preview') else None,
282
                'reorderable_items': reorderable_items,
283
                'paging': paging,
284
                'force_render': force_render,
285 286
            }

287
            fragment = get_preview_fragment(request, xblock, context)
288 289 290 291 292

            # Note that the container view recursively adds headers into the preview fragment,
            # so only the "Pages" view requires that this extra wrapper be included.
            if is_pages_view:
                fragment.content = render_to_string('component.html', {
293
                    'xblock_context': context,
294 295
                    'xblock': xblock,
                    'locator': usage_key,
296
                    'preview': fragment.content,
297
                    'label': xblock.display_name or xblock.scope_ids.block_type,
298
                })
Calen Pennington committed
299 300 301 302 303
        else:
            raise Http404

        hashed_resources = OrderedDict()
        for resource in fragment.resources:
304
            hashed_resources[hash_resource(resource)] = resource._asdict()
Calen Pennington committed
305 306 307 308 309 310 311 312

        return JsonResponse({
            'html': fragment.content,
            'resources': hashed_resources.items()
        })

    else:
        return HttpResponse(status=406)
313 314


315 316 317 318 319 320 321 322 323
@require_http_methods(("GET"))
@login_required
@expect_json
def xblock_outline_handler(request, usage_key_string):
    """
    The restful handler for requests for XBlock information about the block and its children.
    This is used by the course outline in particular to construct the tree representation of
    a course.
    """
Ben McMorran committed
324
    usage_key = usage_key_with_run(usage_key_string)
325
    if not has_studio_read_access(request.user, usage_key.course_key):
326 327
        raise PermissionDenied()

328
    response_format = request.GET.get('format', 'html')
329 330
    if response_format == 'json' or 'application/json' in request.META.get('HTTP_ACCEPT', 'application/json'):
        store = modulestore()
331
        with store.bulk_operations(usage_key.course_key):
332
            root_xblock = store.get_item(usage_key, depth=None)
333 334 335 336 337 338
            return JsonResponse(create_xblock_info(
                root_xblock,
                include_child_info=True,
                course_outline=True,
                include_children_predicate=lambda xblock: not xblock.category == 'vertical'
            ))
339 340 341 342
    else:
        return Http404


343 344 345 346 347 348 349 350 351 352 353
@require_http_methods(("GET"))
@login_required
@expect_json
def xblock_container_handler(request, usage_key_string):
    """
    The restful handler for requests for XBlock information about the block and its children.
    This is used by the container page in particular to get additional information about publish state
    and ancestor state.
    """
    usage_key = usage_key_with_run(usage_key_string)

cahrens committed
354
    if not has_studio_read_access(request.user, usage_key.course_key):
355 356
        raise PermissionDenied()

357
    response_format = request.GET.get('format', 'html')
358 359 360 361 362 363 364 365 366 367
    if response_format == 'json' or 'application/json' in request.META.get('HTTP_ACCEPT', 'application/json'):
        with modulestore().bulk_operations(usage_key.course_key):
            response = _get_module_info(
                _get_xblock(usage_key, request.user), include_ancestor_info=True, include_publishing_info=True
            )
        return JsonResponse(response)
    else:
        return Http404


368 369 370 371 372 373 374 375 376 377 378 379 380
def _update_with_callback(xblock, user, old_metadata=None, old_content=None):
    """
    Updates the xblock in the modulestore.
    But before doing so, it calls the xblock's editor_saved callback function.
    """
    if callable(getattr(xblock, "editor_saved", None)):
        if old_metadata is None:
            old_metadata = own_metadata(xblock)
        if old_content is None:
            old_content = xblock.get_explicitly_set_fields_by_scope(Scope.content)
        xblock.editor_saved(user, old_metadata, old_content)

    # Update after the callback so any changes made in the callback will get persisted.
381
    return modulestore().update_item(xblock, user.id)
382 383 384


def _save_xblock(user, xblock, data=None, children_strings=None, metadata=None, nullout=None,
385
                 grader_type=None, is_prereq=None, prereq_usage_key=None, prereq_min_score=None, publish=None):
386
    """
387
    Saves xblock w/ its fields. Has special processing for grader_type, publish, and nullout and Nones in metadata.
Don Mitchell committed
388 389
    nullout means to truly set the field to None whereas nones in metadata mean to unset them (so they revert
    to default).
390
    """
391
    store = modulestore()
392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422
    # Perform all xblock changes within a (single-versioned) transaction
    with store.bulk_operations(xblock.location.course_key):

        # Don't allow updating an xblock and discarding changes in a single operation (unsupported by UI).
        if publish == "discard_changes":
            store.revert_to_published(xblock.location, user.id)
            # Returning the same sort of result that we do for other save operations. In the future,
            # we may want to return the full XBlockInfo.
            return JsonResponse({'id': unicode(xblock.location)})

        old_metadata = own_metadata(xblock)
        old_content = xblock.get_explicitly_set_fields_by_scope(Scope.content)

        if data:
            # TODO Allow any scope.content fields not just "data" (exactly like the get below this)
            xblock.data = data
        else:
            data = old_content['data'] if 'data' in old_content else None

        if children_strings is not None:
            children = []
            for child_string in children_strings:
                children.append(usage_key_with_run(child_string))

            # if new children have been added, remove them from their old parents
            new_children = set(children) - set(xblock.children)
            for new_child in new_children:
                old_parent_location = store.get_parent_location(new_child)
                if old_parent_location:
                    old_parent = store.get_item(old_parent_location)
                    old_parent.children.remove(new_child)
423
                    old_parent = _update_with_callback(old_parent, user)
424
                else:
425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472
                    # the Studio UI currently doesn't present orphaned children, so assume this is an error
                    return JsonResponse({"error": "Invalid data, possibly caused by concurrent authors."}, 400)

            # make sure there are no old children that became orphans
            # In a single-author (no-conflict) scenario, all children in the persisted list on the server should be
            # present in the updated list.  If there are any children that have been dropped as part of this update,
            # then that would be an error.
            #
            # We can be even more restrictive in a multi-author (conflict), by returning an error whenever
            # len(old_children) > 0. However, that conflict can still be "merged" if the dropped child had been
            # re-parented. Hence, the check for the parent in the any statement below.
            #
            # Note that this multi-author conflict error should not occur in modulestores (such as Split) that support
            # atomic write transactions.  In Split, if there was another author who moved one of the "old_children"
            # into another parent, then that child would have been deleted from this parent on the server. However,
            # this is error could occur in modulestores (such as Draft) that do not support atomic write-transactions
            old_children = set(xblock.children) - set(children)
            if any(
                    store.get_parent_location(old_child) == xblock.location
                    for old_child in old_children
            ):
                # since children are moved as part of a single transaction, orphans should not be created
                return JsonResponse({"error": "Invalid data, possibly caused by concurrent authors."}, 400)

            # set the children on the xblock
            xblock.children = children

        # also commit any metadata which might have been passed along
        if nullout is not None or metadata is not None:
            # the postback is not the complete metadata, as there's system metadata which is
            # not presented to the end-user for editing. So let's use the original (existing_item) and
            # 'apply' the submitted metadata, so we don't end up deleting system metadata.
            if nullout is not None:
                for metadata_key in nullout:
                    setattr(xblock, metadata_key, None)

            # update existing metadata with submitted metadata (which can be partial)
            # IMPORTANT NOTE: if the client passed 'null' (None) for a piece of metadata that means 'remove it'. If
            # the intent is to make it None, use the nullout field
            if metadata is not None:
                for metadata_key, value in metadata.items():
                    field = xblock.fields[metadata_key]

                    if value is None:
                        field.delete_from(xblock)
                    else:
                        try:
                            value = field.from_json(value)
473
                        except ValueError as verr:
E. Kolpakov committed
474 475 476
                            reason = _("Invalid data")
                            if verr.message:
                                reason = _("Invalid data ({details})").format(details=verr.message)
477
                            return JsonResponse({"error": reason}, 400)
478

479 480 481
                        field.write_to(xblock, value)

        # update the xblock and call any xblock callbacks
482
        xblock = _update_with_callback(xblock, user, old_metadata, old_content)
483 484

        # for static tabs, their containing course also records their display name
485
        course = store.get_course(xblock.location.course_key)
486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502
        if xblock.location.category == 'static_tab':
            # find the course's reference to this tab and update the name.
            static_tab = CourseTabList.get_tab_by_slug(course.tabs, xblock.location.name)
            # only update if changed
            if static_tab and static_tab['name'] != xblock.display_name:
                static_tab['name'] = xblock.display_name
                store.update_item(course, user.id)

        result = {
            'id': unicode(xblock.location),
            'data': data,
            'metadata': own_metadata(xblock)
        }

        if grader_type is not None:
            result.update(CourseGradingModel.update_section_grader_type(xblock, grader_type, user))

503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519
        # Save gating info
        if xblock.category == 'sequential' and course.enable_subsection_gating:
            if is_prereq is not None:
                if is_prereq:
                    gating_api.add_prerequisite(xblock.location.course_key, xblock.location)
                else:
                    gating_api.remove_prerequisite(xblock.location)
                result['is_prereq'] = is_prereq

            if prereq_usage_key is not None:
                gating_api.set_required_content(
                    xblock.location.course_key, xblock.location, prereq_usage_key, prereq_min_score
                )

        # If publish is set to 'republish' and this item is not in direct only categories and has previously been
        # published, then this item should be republished. This is used by staff locking to ensure that changing the
        # draft value of the staff lock will also update the published version, but only at the unit level.
520 521 522 523 524 525 526 527 528 529 530
        if publish == 'republish' and xblock.category not in DIRECT_ONLY_CATEGORIES:
            if modulestore().has_published_version(xblock):
                publish = 'make_public'

        # Make public after updating the xblock, in case the caller asked for both an update and a publish.
        # Used by Bok Choy tests and by republishing of staff locks.
        if publish == 'make_public':
            modulestore().publish(xblock.location, user.id)

        # Note that children aren't being returned until we have a use case.
        return JsonResponse(result, encoder=EdxJSONEncoder)
531 532 533 534


@login_required
@expect_json
535 536 537 538 539 540 541 542 543
def create_item(request):
    """
    Exposes internal helper method without breaking existing bindings/dependencies
    """
    return _create_item(request)


@login_required
@expect_json
544
def _create_item(request):
545
    """View for create items."""
546 547
    parent_locator = request.json['parent_locator']
    usage_key = usage_key_with_run(parent_locator)
548 549
    if not has_studio_write_access(request.user, usage_key.course_key):
        raise PermissionDenied()
550

551
    category = request.json['category']
552 553 554 555 556 557 558
    if isinstance(usage_key, LibraryUsageLocator):
        # Only these categories are supported at this time.
        if category not in ['html', 'problem', 'video']:
            return HttpResponseBadRequest(
                "Category '%s' not supported for Libraries" % category, content_type='text/plain'
            )

559 560 561 562 563 564 565 566 567 568 569
    created_block = create_xblock(
        parent_locator=parent_locator,
        user=request.user,
        category=category,
        display_name=request.json.get('display_name'),
        boilerplate=request.json.get('boilerplate')
    )

    return JsonResponse(
        {"locator": unicode(created_block.location), "courseKey": unicode(created_block.location.course_key)}
    )
570 571


572
def _duplicate_item(parent_usage_key, duplicate_source_usage_key, user, display_name=None):
573
    """
574
    Duplicate an existing xblock as a child of the supplied parent_usage_key.
575
    """
576
    store = modulestore()
577 578 579 580 581 582 583
    with store.bulk_operations(duplicate_source_usage_key.course_key):
        source_item = store.get_item(duplicate_source_usage_key)
        # Change the blockID to be unique.
        dest_usage_key = source_item.location.replace(name=uuid4().hex)
        category = dest_usage_key.block_type

        # Update the display name to indicate this is a duplicate (unless display name provided).
E. Kolpakov committed
584 585 586
        # Can't use own_metadata(), b/c it converts data for JSON serialization -
        # not suitable for setting metadata of the new block
        duplicate_metadata = {}
587
        for field in source_item.fields.values():
E. Kolpakov committed
588
            if field.scope == Scope.settings and field.is_set_on(source_item):
589
                duplicate_metadata[field.name] = field.read_from(source_item)
590 591
        if display_name is not None:
            duplicate_metadata['display_name'] = display_name
592
        else:
593 594 595 596 597 598 599 600 601 602 603 604 605 606
            if source_item.display_name is None:
                duplicate_metadata['display_name'] = _("Duplicate of {0}").format(source_item.category)
            else:
                duplicate_metadata['display_name'] = _("Duplicate of '{0}'").format(source_item.display_name)

        dest_module = store.create_item(
            user.id,
            dest_usage_key.course_key,
            dest_usage_key.block_type,
            block_id=dest_usage_key.block_id,
            definition_data=source_item.get_explicitly_set_fields_by_scope(Scope.content),
            metadata=duplicate_metadata,
            runtime=source_item.runtime,
        )
607

608
        children_handled = False
609 610 611 612 613

        if hasattr(dest_module, 'studio_post_duplicate'):
            # Allow an XBlock to do anything fancy it may need to when duplicated from another block.
            # These blocks may handle their own children or parenting if needed. Let them return booleans to
            # let us know if we need to handle these or not.
614
            children_handled = dest_module.studio_post_duplicate(store, source_item)
615

616 617
        # Children are not automatically copied over (and not all xblocks have a 'children' attribute).
        # Because DAGs are not fully supported, we need to actually duplicate each child as well.
618
        if source_item.has_children and not children_handled:
619
            dest_module.children = dest_module.children or []
620 621
            for child in source_item.children:
                dupe = _duplicate_item(dest_module.location, child, user=user)
622 623
                if dupe not in dest_module.children:  # _duplicate_item may add the child for us.
                    dest_module.children.append(dupe)
624 625
            store.update_item(dest_module, user.id)

626
        # pylint: disable=protected-access
627
        if 'detached' not in source_item.runtime.load_block_type(category)._class_tags:
628 629 630 631 632 633 634 635 636 637 638
            parent = store.get_item(parent_usage_key)
            # If source was already a child of the parent, add duplicate immediately afterward.
            # Otherwise, add child to end.
            if source_item.location in parent.children:
                source_index = parent.children.index(source_item.location)
                parent.children.insert(source_index + 1, dest_module.location)
            else:
                parent.children.append(dest_module.location)
            store.update_item(parent, user.id)

        return dest_module.location
639 640


641 642 643 644 645 646 647 648 649
@login_required
@expect_json
def delete_item(request, usage_key):
    """
    Exposes internal helper method without breaking existing bindings/dependencies
    """
    _delete_item(usage_key, request.user)


650
def _delete_item(usage_key, user):
651
    """
652 653
    Deletes an existing xblock with the given usage_key.
    If the xblock is a Static Tab, removes it from course.tabs as well.
654
    """
655
    store = modulestore()
656

657 658 659 660 661
    with store.bulk_operations(usage_key.course_key):
        # VS[compat] cdodge: This is a hack because static_tabs also have references from the course module, so
        # if we add one then we need to also add it to the policy information (i.e. metadata)
        # we should remove this once we can break this reference from the course to static tabs
        if usage_key.category == 'static_tab':
662 663 664 665 666 667 668 669 670

            dog_stats_api.increment(
                DEPRECATION_VSCOMPAT_EVENT,
                tags=(
                    "location:_delete_item_static_tab",
                    u"course:{}".format(unicode(usage_key.course_key)),
                )
            )

671 672 673 674
            course = store.get_course(usage_key.course_key)
            existing_tabs = course.tabs or []
            course.tabs = [tab for tab in existing_tabs if tab.get('url_slug') != usage_key.name]
            store.update_item(course, user.id)
675

676
        store.delete_item(usage_key, user.id)
677

678 679

@login_required
680
@require_http_methods(("GET", "DELETE"))
681
def orphan_handler(request, course_key_string):
682
    """
683 684
    View for handling orphan related requests. GET gets all of the current orphans.
    DELETE removes all orphans (requires is_staff access)
685 686 687 688

    An orphan is a block whose category is not in the DETACHED_CATEGORY list, is not the root, and is not reachable
    from the root via children
    """
689
    course_usage_key = CourseKey.from_string(course_key_string)
690
    if request.method == 'GET':
691
        if has_studio_read_access(request.user, course_usage_key):
692
            return JsonResponse([unicode(item) for item in modulestore().get_orphans(course_usage_key)])
693 694 695 696
        else:
            raise PermissionDenied()
    if request.method == 'DELETE':
        if request.user.is_staff:
697 698
            deleted_items = _delete_orphans(course_usage_key, request.user.id, commit=True)
            return JsonResponse({'deleted': deleted_items})
699 700
        else:
            raise PermissionDenied()
701 702


703 704 705 706 707 708 709 710
def _delete_orphans(course_usage_key, user_id, commit=False):
    """
    Helper function to delete orphans for a given course.
    If `commit` is False, this function does not actually remove
    the orphans.
    """
    store = modulestore()
    items = store.get_orphans(course_usage_key)
711
    branch = course_usage_key.branch
712
    if commit:
713 714 715 716 717 718 719
        with store.bulk_operations(course_usage_key):
            for itemloc in items:
                revision = ModuleStoreEnum.RevisionOption.all
                # specify branches when deleting orphans
                if branch == ModuleStoreEnum.BranchName.published:
                    revision = ModuleStoreEnum.RevisionOption.published_only
                store.delete_item(itemloc, user_id, revision=revision)
720 721 722
    return [unicode(item) for item in items]


723
def _get_xblock(usage_key, user):
724
    """
725 726
    Returns the xblock for the specified usage key. Note: if failing to find a key with a category
    in the CREATE_IF_NOT_FOUND list, an xblock will be created and saved automatically.
727
    """
728
    store = modulestore()
729 730 731 732 733 734 735 736 737 738 739 740
    with store.bulk_operations(usage_key.course_key):
        try:
            return store.get_item(usage_key, depth=None)
        except ItemNotFoundError:
            if usage_key.category in CREATE_IF_NOT_FOUND:
                # Create a new one for certain categories only. Used for course info handouts.
                return store.create_item(user.id, usage_key.course_key, usage_key.block_type, block_id=usage_key.block_id)
            else:
                raise
        except InvalidLocationError:
            log.error("Can't find item by location.")
            return JsonResponse({"error": "Can't find item by location: " + unicode(usage_key)}, 404)
741

742

743
def _get_module_info(xblock, rewrite_static_links=True, include_ancestor_info=False, include_publishing_info=False):
744 745 746 747
    """
    metadata, data, id representation of a leaf module fetcher.
    :param usage_key: A UsageKey
    """
748 749 750 751 752 753 754 755
    with modulestore().bulk_operations(xblock.location.course_key):
        data = getattr(xblock, 'data', '')
        if rewrite_static_links:
            data = replace_static_urls(
                data,
                None,
                course_id=xblock.location.course_key
            )
756

757
        # Pre-cache has changes for the entire course because we'll need it for the ancestor info
758 759 760
        # Except library blocks which don't [yet] use draft/publish
        if not isinstance(xblock.location, LibraryUsageLocator):
            modulestore().has_changes(modulestore().get_course(xblock.location.course_key, depth=None))
761

762
        # Note that children aren't being returned until we have a use case.
763 764 765 766 767 768
        xblock_info = create_xblock_info(
            xblock, data=data, metadata=own_metadata(xblock), include_ancestor_info=include_ancestor_info
        )
        if include_publishing_info:
            add_container_page_publishing_info(xblock, xblock_info)
        return xblock_info
769 770


771 772 773 774 775 776 777 778 779 780 781 782 783 784
def _get_gating_info(course, xblock):
    """
    Returns a dict containing gating information for the given xblock which
    can be added to xblock info responses.

    Arguments:
        course (CourseDescriptor): The course
        xblock (XBlock): The xblock

    Returns:
        dict: Gating information
    """
    info = {}
    if xblock.category == 'sequential' and course.enable_subsection_gating:
785 786 787 788
        if not hasattr(course, 'gating_prerequisites'):
            # Cache gating prerequisites on course module so that we are not
            # hitting the database for every xblock in the course
            setattr(course, 'gating_prerequisites', gating_api.get_prerequisites(course.id))  # pylint: disable=literal-used-as-attribute
789 790 791 792 793 794 795 796 797 798 799 800 801 802 803
        info["is_prereq"] = gating_api.is_prerequisite(course.id, xblock.location)
        info["prereqs"] = [
            p for p in course.gating_prerequisites if unicode(xblock.location) not in p['namespace']
        ]
        prereq, prereq_min_score = gating_api.get_required_content(
            course.id,
            xblock.location
        )
        info["prereq"] = prereq
        info["prereq_min_score"] = prereq_min_score
        if prereq:
            info["visibility_state"] = VisibilityState.gated
    return info


804
def create_xblock_info(xblock, data=None, metadata=None, include_ancestor_info=False, include_child_info=False,
805
                       course_outline=False, include_children_predicate=NEVER, parent_xblock=None, graders=None,
806
                       user=None, course=None):
807 808 809 810 811
    """
    Creates the information needed for client-side XBlockInfo.

    If data or metadata are not specified, their information will not be added
    (regardless of whether or not the xblock actually has data or metadata).
812

813
    There are three optional boolean parameters:
814 815
      include_ancestor_info - if true, ancestor info is added to the response
      include_child_info - if true, direct child info is included in the response
816 817
      course_outline - if true, the xblock is being rendered on behalf of the course outline.
        There are certain expensive computations that do not need to be included in this case.
818 819 820

    In addition, an optional include_children_predicate argument can be provided to define whether or
    not a particular xblock should have its children included.
821
    """
822
    is_library_block = isinstance(xblock.location, LibraryUsageLocator)
823
    is_xblock_unit = is_unit(xblock, parent_xblock)
824
    # this should not be calculated for Sections and Subsections on Unit page or for library blocks
E. Kolpakov committed
825 826 827
    has_changes = None
    if (is_xblock_unit or course_outline) and not is_library_block:
        has_changes = modulestore().has_changes(xblock)
828

829
    if graders is None:
830 831 832 833
        if not is_library_block:
            graders = CourseGradingModel.fetch(xblock.location.course_key).graders
        else:
            graders = []
834

835 836 837
    # Filter the graders data as needed
    graders = _filter_entrance_exam_grader(graders)

838 839 840 841 842
    # We need to load the course in order to retrieve user partition information.
    # For this reason, we load the course once and re-use it when recursively loading children.
    if course is None:
        course = modulestore().get_course(xblock.location.course_key)

843
    # Compute the child info first so it can be included in aggregate information for the parent
844 845
    should_visit_children = include_child_info and (course_outline and not is_xblock_unit or not course_outline)
    if should_visit_children and xblock.has_children:
846
        child_info = _create_xblock_child_info(
847 848
            xblock,
            course_outline,
849 850
            graders,
            include_children_predicate=include_children_predicate,
851 852
            user=user,
            course=course
853 854 855 856
        )
    else:
        child_info = None

857 858
    release_date = _get_release_date(xblock, user)

859
    if xblock.category != 'course':
860 861 862
        visibility_state = _compute_visibility_state(
            xblock, child_info, is_xblock_unit and has_changes, is_self_paced(course)
        )
863 864
    else:
        visibility_state = None
865
    published = modulestore().has_published_version(xblock) if not is_library_block else None
866

867 868 869
    # defining the default value 'True' for delete, drag and add new child actions in xblock_actions for each xblock.
    xblock_actions = {'deletable': True, 'draggable': True, 'childAddable': True}
    explanatory_message = None
870

871 872 873 874 875 876 877
    # is_entrance_exam is inherited metadata.
    if xblock.category == 'chapter' and getattr(xblock, "is_entrance_exam", None):
        # Entrance exam section should not be deletable, draggable and not have 'New Subsection' button.
        xblock_actions['deletable'] = xblock_actions['childAddable'] = xblock_actions['draggable'] = False
        if parent_xblock is None:
            parent_xblock = get_parent_xblock(xblock)

louyihua committed
878 879 880 881 882 883 884 885
        # Translators: The {pct_sign} here represents the percent sign, i.e., '%'
        # in many languages. This is used to avoid Transifex's misinterpreting of
        # '% o'. The percent sign is also translatable as a standalone string.
        explanatory_message = _('Students must score {score}{pct_sign} or higher to access course materials.').format(
            score=int(parent_xblock.entrance_exam_minimum_score_pct * 100),
            # Translators: This is the percent sign. It will be used to represent
            # a percent value out of 100, e.g. "58%" means "58/100".
            pct_sign=_('%'))
886

887 888
    xblock_info = {
        "id": unicode(xblock.location),
889
        "display_name": xblock.display_name_with_default_escaped,
890
        "category": xblock.category,
891
        "edited_on": get_default_time_display(xblock.subtree_edited_on) if xblock.subtree_edited_on else None,
892
        "published": published,
893
        "published_on": get_default_time_display(xblock.published_on) if published and xblock.published_on else None,
894
        "studio_url": xblock_studio_url(xblock, parent_xblock),
895
        "released_to_students": datetime.now(UTC) > xblock.start,
896
        "release_date": release_date,
897 898
        "visibility_state": visibility_state,
        "has_explicit_staff_lock": xblock.fields['visible_to_staff_only'].is_set_on(xblock),
899 900 901 902 903
        "start": xblock.fields['start'].to_json(xblock.start),
        "graded": xblock.graded,
        "due_date": get_default_time_display(xblock.due),
        "due": xblock.fields['due'].to_json(xblock.due),
        "format": xblock.format,
904
        "course_graders": [grader.get('type') for grader in graders],
905
        "has_changes": has_changes,
906
        "actions": xblock_actions,
907
        "explanatory_message": explanatory_message,
908 909
        "group_access": xblock.group_access,
        "user_partitions": get_user_partition_info(xblock, course=course),
910
    }
911

912 913
    # update xblock_info with special exam information if the feature flag is enabled
    if settings.FEATURES.get('ENABLE_SPECIAL_EXAMS'):
914 915
        if xblock.category == 'course':
            xblock_info.update({
916 917
                "enable_proctored_exams": xblock.enable_proctored_exams,
                "enable_timed_exams": xblock.enable_timed_exams
918 919 920
            })
        elif xblock.category == 'sequential':
            xblock_info.update({
921 922
                "is_proctored_exam": xblock.is_proctored_exam,
                "is_practice_exam": xblock.is_practice_exam,
923
                "is_time_limited": xblock.is_time_limited,
Muhammad Shoaib committed
924
                "exam_review_rules": xblock.exam_review_rules,
925
                "default_time_limit_minutes": xblock.default_time_limit_minutes
926 927
            })

928 929 930 931 932 933 934 935
    # Update with gating info
    xblock_info.update(_get_gating_info(course, xblock))

    if xblock.category == 'sequential':
        # Entrance exam subsection should be hidden. in_entrance_exam is
        # inherited metadata, all children will have it.
        if getattr(xblock, "in_entrance_exam", False):
            xblock_info["is_header_visible"] = False
936

937 938 939 940
    if data is not None:
        xblock_info["data"] = data
    if metadata is not None:
        xblock_info["metadata"] = metadata
941
    if include_ancestor_info:
942
        xblock_info['ancestor_info'] = _create_xblock_ancestor_info(xblock, course_outline)
943 944
    if child_info:
        xblock_info['child_info'] = child_info
945 946 947 948 949 950 951 952 953 954 955 956
    if visibility_state == VisibilityState.staff_only:
        xblock_info["ancestor_has_staff_lock"] = ancestor_has_staff_lock(xblock, parent_xblock)
    else:
        xblock_info["ancestor_has_staff_lock"] = False

    if course_outline:
        if xblock_info["has_explicit_staff_lock"]:
            xblock_info["staff_only_message"] = True
        elif child_info and child_info["children"]:
            xblock_info["staff_only_message"] = all([child["staff_only_message"] for child in child_info["children"]])
        else:
            xblock_info["staff_only_message"] = False
957

958
    return xblock_info
959 960


961 962 963 964 965 966 967 968 969 970 971 972 973 974 975 976 977 978 979 980 981 982 983 984 985 986 987 988 989 990 991 992 993 994
def add_container_page_publishing_info(xblock, xblock_info):  # pylint: disable=invalid-name
    """
    Adds information about the xblock's publish state to the supplied
    xblock_info for the container page.
    """
    def safe_get_username(user_id):
        """
        Guard against bad user_ids, like the infamous "**replace_user**".
        Note that this will ignore our special known IDs (ModuleStoreEnum.UserID).
        We should consider adding special handling for those values.

        :param user_id: the user id to get the username of
        :return: username, or None if the user does not exist or user_id is None
        """
        if user_id:
            try:
                return User.objects.get(id=user_id).username
            except:  # pylint: disable=bare-except
                pass

        return None

    xblock_info["edited_by"] = safe_get_username(xblock.subtree_edited_by)
    xblock_info["published_by"] = safe_get_username(xblock.published_by)
    xblock_info["currently_visible_to_students"] = is_currently_visible_to_students(xblock)
    xblock_info["has_content_group_components"] = has_children_visible_to_specific_content_groups(xblock)
    if xblock_info["release_date"]:
        xblock_info["release_date_from"] = _get_release_date_from(xblock)
    if xblock_info["visibility_state"] == VisibilityState.staff_only:
        xblock_info["staff_lock_from"] = _get_staff_lock_from(xblock)
    else:
        xblock_info["staff_lock_from"] = None


995
class VisibilityState(object):
996
    """
997 998 999 1000 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010
    Represents the possible visibility states for an xblock:

      live - the block and all of its descendants are live to students (excluding staff only items)
        Note: Live means both published and released.

      ready - the block is ready to go live and all of its descendants are live or ready (excluding staff only items)
        Note: content is ready when it is published and scheduled with a release date in the future.

      unscheduled - the block and all of its descendants have no release date (excluding staff only items)
        Note: it is valid for items to be published with no release date in which case they are still unscheduled.

      needs_attention - the block or its descendants are not fully live, ready or unscheduled (excluding staff only items)
        For example: one subsection has draft content, or there's both unreleased and released content in one section.

1011
      staff_only - all of the block's content is to be shown to staff only
1012
        Note: staff only items do not affect their parent's state.
1013 1014

      gated - all of the block's content is to be shown to students only after the configured prerequisite is met
1015 1016 1017
    """
    live = 'live'
    ready = 'ready'
1018
    unscheduled = 'unscheduled'
1019
    needs_attention = 'needs_attention'
1020
    staff_only = 'staff_only'
1021
    gated = 'gated'
1022 1023


1024
def _compute_visibility_state(xblock, child_info, is_unit_with_changes, is_course_self_paced=False):
1025 1026 1027 1028
    """
    Returns the current publish state for the specified xblock and its children
    """
    if xblock.visible_to_staff_only:
1029
        return VisibilityState.staff_only
1030 1031 1032
    elif is_unit_with_changes:
        # Note that a unit that has never been published will fall into this category,
        # as well as previously published units with draft content.
1033
        return VisibilityState.needs_attention
1034

1035
    is_unscheduled = xblock.start == DEFAULT_START_DATE
1036 1037
    is_live = is_course_self_paced or datetime.now(UTC) > xblock.start
    if child_info and child_info.get('children', []):
1038 1039 1040 1041
        all_staff_only = True
        all_unscheduled = True
        all_live = True
        for child in child_info['children']:
1042 1043
            child_state = child['visibility_state']
            if child_state == VisibilityState.needs_attention:
1044
                return child_state
1045
            elif not child_state == VisibilityState.staff_only:
1046
                all_staff_only = False
1047
                if not child_state == VisibilityState.unscheduled:
1048
                    all_unscheduled = False
1049
                    if not child_state == VisibilityState.live:
1050 1051
                        all_live = False
        if all_staff_only:
1052
            return VisibilityState.staff_only
1053
        elif all_unscheduled:
1054
            return VisibilityState.unscheduled if is_unscheduled else VisibilityState.needs_attention
1055
        elif all_live:
1056
            return VisibilityState.live if is_live else VisibilityState.needs_attention
1057
        else:
1058
            return VisibilityState.ready if not is_unscheduled else VisibilityState.needs_attention
1059
    if is_unscheduled:
1060 1061 1062
        return VisibilityState.unscheduled
    elif is_live:
        return VisibilityState.live
1063
    else:
1064
        return VisibilityState.ready
1065 1066


1067
def _create_xblock_ancestor_info(xblock, course_outline):
1068 1069 1070 1071 1072 1073 1074 1075 1076 1077 1078 1079 1080 1081 1082
    """
    Returns information about the ancestors of an xblock. Note that the direct parent will also return
    information about all of its children.
    """
    ancestors = []

    def collect_ancestor_info(ancestor, include_child_info=False):
        """
        Collect xblock info regarding the specified xblock and its ancestors.
        """
        if ancestor:
            direct_children_only = lambda parent: parent == ancestor
            ancestors.append(create_xblock_info(
                ancestor,
                include_child_info=include_child_info,
1083
                course_outline=course_outline,
1084 1085 1086 1087 1088 1089 1090 1091 1092
                include_children_predicate=direct_children_only
            ))
            collect_ancestor_info(get_parent_xblock(ancestor))
    collect_ancestor_info(get_parent_xblock(xblock), include_child_info=True)
    return {
        'ancestors': ancestors
    }


1093
def _create_xblock_child_info(xblock, course_outline, graders, include_children_predicate=NEVER, user=None, course=None):  # pylint: disable=line-too-long
1094 1095 1096 1097 1098 1099 1100 1101 1102 1103 1104 1105 1106 1107
    """
    Returns information about the children of an xblock, as well as about the primary category
    of xblock expected as children.
    """
    child_info = {}
    child_category = xblock_primary_child_category(xblock)
    if child_category:
        child_info = {
            'category': child_category,
            'display_name': xblock_type_display_name(child_category, default_display_name=child_category),
        }
    if xblock.has_children and include_children_predicate(xblock):
        child_info['children'] = [
            create_xblock_info(
1108
                child, include_child_info=True, course_outline=course_outline,
1109
                include_children_predicate=include_children_predicate,
1110
                parent_xblock=xblock,
1111
                graders=graders,
1112 1113
                user=user,
                course=course,
1114 1115 1116
            ) for child in xblock.get_children()
        ]
    return child_info
1117 1118


1119
def _get_release_date(xblock, user=None):
1120 1121 1122
    """
    Returns the release date for the xblock, or None if the release date has never been set.
    """
1123
    # If year of start date is less than 1900 then reset the start date to DEFAULT_START_DATE
1124 1125 1126 1127 1128 1129 1130 1131 1132
    reset_to_default = False
    try:
        reset_to_default = xblock.start.year < 1900
    except ValueError:
        # For old mongo courses, accessing the start attribute calls `to_json()`,
        # which raises a `ValueError` for years < 1900.
        reset_to_default = True

    if reset_to_default and user:
1133 1134 1135
        xblock.start = DEFAULT_START_DATE
        xblock = _update_with_callback(xblock, user)

1136 1137 1138 1139
    # Treat DEFAULT_START_DATE as a magic number that means the release date has not been set
    return get_default_time_display(xblock.start) if xblock.start != DEFAULT_START_DATE else None


1140 1141 1142 1143
def _get_release_date_from(xblock):
    """
    Returns a string representation of the section or subsection that sets the xblock's release date
    """
1144 1145 1146 1147 1148 1149 1150 1151 1152 1153 1154 1155 1156 1157 1158
    return _xblock_type_and_display_name(find_release_date_source(xblock))


def _get_staff_lock_from(xblock):
    """
    Returns a string representation of the section or subsection that sets the xblock's release date
    """
    source = find_staff_lock_source(xblock)
    return _xblock_type_and_display_name(source) if source else None


def _xblock_type_and_display_name(xblock):
    """
    Returns a string representation of the xblock's type and display name
    """
1159
    return _('{section_or_subsection} "{display_name}"').format(
1160
        section_or_subsection=xblock_type_display_name(xblock),
1161
        display_name=xblock.display_name_with_default_escaped)