391 lines
16 KiB
Python
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()
|