Files
edx-platform/cms/djangoapps/contentstore/views/block.py

391 lines
16 KiB
Python

"""Views for blocks."""
import logging
from collections import OrderedDict
from functools import partial
from django.contrib.auth.decorators import login_required
from django.core.exceptions import PermissionDenied
from django.db import transaction
from django.http import Http404, HttpResponse
from django.utils.translation import gettext as _
from django.views.decorators.http import require_http_methods
from opaque_keys.edx.keys import CourseKey
from web_fragments.fragment import Fragment
from cms.djangoapps.contentstore.utils import load_services_for_studio
from cms.lib.xblock.authoring_mixin import VISIBILITY_VIEW
from common.djangoapps.edxmako.shortcuts import render_to_string
from common.djangoapps.student.auth import (
has_studio_read_access,
has_studio_write_access,
)
from common.djangoapps.util.json_request import JsonResponse, expect_json
from openedx.core.lib.xblock_utils import (
hash_resource,
request_token,
wrap_xblock,
wrap_xblock_aside,
)
from xmodule.modulestore.django import (
modulestore,
) # lint-amnesty, pylint: disable=wrong-import-order
from openedx.core.djangoapps.content_tagging.toggles import is_tagging_feature_disabled
from xmodule.x_module import (
AUTHOR_VIEW,
PREVIEW_VIEWS,
STUDENT_VIEW,
STUDIO_VIEW,
) # lint-amnesty, pylint: disable=wrong-import-order
from ..helpers import (
is_unit,
)
from .preview import get_preview_fragment
from cms.djangoapps.contentstore.xblock_storage_handlers.view_handlers import (
handle_xblock,
create_xblock_info,
get_block_info,
get_xblock,
delete_orphans,
)
from cms.djangoapps.contentstore.xblock_storage_handlers.xblock_helpers import (
usage_key_with_run,
get_children_tags_count,
)
__all__ = [
"orphan_handler",
"xblock_handler",
"xblock_view_handler",
"xblock_outline_handler",
"xblock_container_handler",
]
log = logging.getLogger(__name__)
CREATE_IF_NOT_FOUND = ["course_info"]
# Useful constants for defining predicates
NEVER = lambda x: False
ALWAYS = lambda x: True
# Disable atomic requests so transactions made during the request commit immediately instead of waiting for the end of
# the request transaction. This is necessary so the async tasks launched by the current process can see the changes made
# during the request. One example is the async tasks launched when courses are published before the request
# ends, which end up reading from an outdated state of the database. For more information see the discussion in the
# following PR: https://github.com/openedx/edx-platform/pull/34020
@transaction.non_atomic_requests
@require_http_methods(("DELETE", "GET", "PUT", "POST", "PATCH"))
@login_required
@expect_json
def xblock_handler(request, usage_key_string=None):
"""
The restful handler for xblock requests.
DELETE
json: delete this xblock instance from the course.
GET
json: returns representation of the xblock (locator id, data, and metadata).
if ?fields=graderType, it returns the graderType for the unit instead of the above.
if ?fields=ancestorInfo, it returns ancestor info of the xblock.
html: returns HTML for rendering the xblock (which includes both the "preview" view and the "editor" view)
PUT or POST or PATCH
json: if xblock locator is specified, update the xblock instance. The json payload can contain
these fields, all optional:
:data: the new value for the data.
:children: the unicode representation of the UsageKeys of children for this xblock.
: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.
:fields: any other xblock fields to be set. Only supported by update.
This is represented as a dictionary:
{'field_name': 'field_value'}
:nullout: which metadata fields to set to None
:graderType: change how this unit is graded
: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. Ranging from 0 to 100.
:prereqMinCompletion: The minimum completion percentage that needs to be achieved on the
prerequisite xblock identifed by prereqUsageKey. Ranging from 0 to 100.
: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.
The JSON representation on the updated xblock (minus children) is returned.
if usage_key_string is not specified, create a new xblock instance, either by duplicating
an existing xblock, or creating an entirely new one. The json playload can contain
these fields:
:parent_locator: parent for new xblock, required for duplicate, move and create new instance
:duplicate_source_locator: if present, use this as the source for creating a duplicate copy
:move_source_locator: if present, use this as the source item for moving
:target_index: if present, use this as the target index for moving an item to a particular index
otherwise target_index is calculated. It is sent back in the response.
:category: type of xblock, required if duplicate_source_locator is not present.
:display_name: name for new xblock, optional
:boilerplate: template name for populating fields, optional and only used
if duplicate_source_locator is not present
:staged_content: use "clipboard" to paste from the OLX user's clipboard. (Incompatible with all other
fields except parent_locator)
The locator (unicode representation of a UsageKey) for the created xblock (minus children) is returned.
"""
return handle_xblock(request, usage_key_string)
@require_http_methods("GET")
@login_required
@expect_json
def xblock_view_handler(request, usage_key_string, view_name):
"""
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
"""
usage_key = usage_key_with_run(usage_key_string)
if not has_studio_read_access(request.user, usage_key.course_key):
raise PermissionDenied()
accept_header = request.META.get("HTTP_ACCEPT", "application/json")
if "application/json" in accept_header:
store = modulestore()
xblock = store.get_item(usage_key)
container_views = [
"container_preview",
"reorderable_container_child_preview",
"container_child_preview",
]
# wrap the generated fragment in the xmodule_editor div so that the javascript
# can bind to it correctly
xblock.runtime.wrappers.append(
partial(
wrap_xblock,
"StudioRuntime",
usage_id_serializer=str,
request_token=request_token(request),
)
)
xblock.runtime.wrappers_asides.append(
partial(
wrap_xblock_aside,
"StudioRuntime",
usage_id_serializer=str,
request_token=request_token(request),
extra_classes=["wrapper-comp-plugins"],
)
)
if view_name in (STUDIO_VIEW, VISIBILITY_VIEW):
if view_name == STUDIO_VIEW:
load_services_for_studio(xblock.runtime, request.user)
try:
fragment = xblock.render(view_name)
# catch exceptions indiscriminately, since after this point they escape the
# dungeon and surface as uneditable, unsaveable, and undeletable
# component-goblins.
except Exception as exc: # pylint: disable=broad-except
log.debug(
"Unable to render %s for %r", view_name, xblock, exc_info=True
)
fragment = Fragment(
render_to_string("html_error.html", {"message": str(exc)})
)
elif view_name in PREVIEW_VIEWS + container_views:
is_pages_view = (
view_name == STUDENT_VIEW
) # Only the "Pages" view uses student view in Studio
can_edit = has_studio_write_access(request.user, usage_key.course_key)
# 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()
if view_name == "reorderable_container_child_preview":
reorderable_items.add(xblock.location)
paging = None
try:
if request.GET.get("enable_paging", "false") == "true":
paging = {
"page_number": int(request.GET.get("page_number", 0)),
"page_size": int(request.GET.get("page_size", 0)),
}
except ValueError:
return HttpResponse(
content="Couldn't parse paging parameters: enable_paging: "
"{}, page_number: {}, page_size: {}".format(
request.GET.get("enable_paging", "false"),
request.GET.get("page_number", 0),
request.GET.get("page_size", 0),
),
status=400,
content_type="text/plain",
)
force_render = request.GET.get("force_render", None)
# Fetch tags of children components
tags_count_map = {}
if not is_tagging_feature_disabled():
tags_count_map = get_children_tags_count(xblock)
# Set up the context to be passed to each XBlock's render method.
context = request.GET.dict()
context.update(
{
# This setting disables the recursive wrapping of xblocks
"is_pages_view": is_pages_view or view_name == AUTHOR_VIEW,
"is_unit_page": is_unit(xblock),
"can_edit": can_edit,
"root_xblock": xblock
if (view_name == "container_preview")
else None,
"reorderable_items": reorderable_items,
"paging": paging,
"force_render": force_render,
"item_url": "/container/{usage_key}",
"tags_count_map": tags_count_map,
}
)
fragment = get_preview_fragment(request, xblock, context)
# Note that the container view recursively adds headers into the preview fragment,
# so only the "Pages" view requires that this extra wrapper be included.
display_label = xblock.display_name or xblock.scope_ids.block_type
if not xblock.display_name and xblock.scope_ids.block_type == "html":
display_label = _("Text")
if is_pages_view:
fragment.content = render_to_string(
"component.html",
{
"xblock_context": context,
"xblock": xblock,
"locator": usage_key,
"preview": fragment.content,
"label": display_label,
},
)
else:
raise Http404
hashed_resources = OrderedDict()
for resource in fragment.resources:
hashed_resources[hash_resource(resource)] = resource._asdict()
fragment_content = fragment.content
if isinstance(fragment_content, bytes):
fragment_content = fragment.content.decode("utf-8")
return JsonResponse(
{"html": fragment_content, "resources": list(hashed_resources.items())}
)
else:
return HttpResponse(status=406)
@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.
"""
usage_key = usage_key_with_run(usage_key_string)
if not has_studio_read_access(request.user, usage_key.course_key):
raise PermissionDenied()
response_format = request.GET.get("format", "html")
if response_format == "json" or "application/json" in request.META.get(
"HTTP_ACCEPT", "application/json"
):
store = modulestore()
with store.bulk_operations(usage_key.course_key):
root_xblock = store.get_item(usage_key, depth=None)
return JsonResponse(
create_xblock_info(
root_xblock,
include_child_info=True,
course_outline=True,
include_children_predicate=lambda xblock: not xblock.category
== "vertical",
)
)
else:
raise Http404
@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)
if not has_studio_read_access(request.user, usage_key.course_key):
raise PermissionDenied()
response_format = request.GET.get("format", "html")
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_block_info(
get_xblock(usage_key, request.user),
include_ancestor_info=True,
include_publishing_info=True,
)
return JsonResponse(response)
else:
raise Http404
@login_required
@require_http_methods(("GET", "DELETE"))
def orphan_handler(request, course_key_string):
"""
View for handling orphan related requests. GET gets all of the current orphans.
DELETE removes all orphans (requires is_staff access)
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
"""
course_usage_key = CourseKey.from_string(course_key_string)
if request.method == "GET":
if has_studio_read_access(request.user, course_usage_key):
return JsonResponse(
[str(item) for item in modulestore().get_orphans(course_usage_key)]
)
else:
raise PermissionDenied()
if request.method == "DELETE":
if request.user.is_staff:
deleted_items = delete_orphans(
course_usage_key, request.user.id, commit=True
)
return JsonResponse({"deleted": deleted_items})
else:
raise PermissionDenied()