Learning Contexts, New XBlock Runtime, Blockstore API Client + Content Libraries
https://github.com/edx/edx-platform/pull/20645 This introduces: * A new XBlock runtime that can read and write XBlocks that are persisted using Blockstore instead of Modulestore. The new runtime is currently isolated so that it can be tested without risk to the current courseware/runtime. * Content Libraries v2, which store XBlocks in Blockstore not modulestore * An API Client for Blockstore * "Learning Context" plugin API. A learning context is a more abstract concept than a course; it's a collection of XBlocks that serves some learning purpose.
This commit is contained in:
33
openedx/core/djangoapps/content_libraries/admin.py
Normal file
33
openedx/core/djangoapps/content_libraries/admin.py
Normal file
@@ -0,0 +1,33 @@
|
||||
"""
|
||||
Admin site for content libraries
|
||||
"""
|
||||
from django.contrib import admin
|
||||
from .models import ContentLibrary, ContentLibraryPermission
|
||||
|
||||
|
||||
class ContentLibraryPermissionInline(admin.TabularInline):
|
||||
"""
|
||||
Inline form for a content library's permissions
|
||||
"""
|
||||
model = ContentLibraryPermission
|
||||
raw_id_fields = ("user", )
|
||||
extra = 0
|
||||
|
||||
|
||||
@admin.register(ContentLibrary)
|
||||
class ContentLibraryAdmin(admin.ModelAdmin):
|
||||
"""
|
||||
Definition of django admin UI for Content Libraries
|
||||
"""
|
||||
fields = ("library_key", "org", "slug", "bundle_uuid", "allow_public_learning", "allow_public_read")
|
||||
list_display = ("slug", "org", "bundle_uuid")
|
||||
inlines = (ContentLibraryPermissionInline, )
|
||||
|
||||
def get_readonly_fields(self, request, obj=None):
|
||||
"""
|
||||
Ensure that 'slug' and 'uuid' cannot be edited after creation.
|
||||
"""
|
||||
if obj:
|
||||
return ["library_key", "org", "slug", "bundle_uuid"]
|
||||
else:
|
||||
return ["library_key", ]
|
||||
485
openedx/core/djangoapps/content_libraries/api.py
Normal file
485
openedx/core/djangoapps/content_libraries/api.py
Normal file
@@ -0,0 +1,485 @@
|
||||
"""
|
||||
Python API for content libraries
|
||||
"""
|
||||
from __future__ import absolute_import, division, print_function, unicode_literals
|
||||
from uuid import UUID
|
||||
import logging
|
||||
|
||||
import attr
|
||||
from django.core.validators import validate_unicode_slug
|
||||
from django.db import IntegrityError
|
||||
from lxml import etree
|
||||
from organizations.models import Organization
|
||||
import six
|
||||
from xblock.core import XBlock
|
||||
from xblock.exceptions import XBlockNotFoundError
|
||||
|
||||
from cms.djangoapps.contentstore.views.helpers import xblock_type_display_name
|
||||
from openedx.core.djangoapps.content_libraries.library_bundle import LibraryBundle
|
||||
from openedx.core.djangoapps.xblock.api import get_block_display_name, load_block
|
||||
from openedx.core.djangoapps.xblock.learning_context.keys import BundleDefinitionLocator
|
||||
from openedx.core.djangoapps.xblock.learning_context.manager import get_learning_context_impl
|
||||
from openedx.core.djangoapps.xblock.runtime.olx_parsing import XBlockInclude
|
||||
from openedx.core.lib.blockstore_api import (
|
||||
get_bundle,
|
||||
get_bundle_file_data,
|
||||
get_bundle_files,
|
||||
get_or_create_bundle_draft,
|
||||
create_bundle,
|
||||
update_bundle,
|
||||
delete_bundle,
|
||||
write_draft_file,
|
||||
commit_draft,
|
||||
delete_draft,
|
||||
)
|
||||
from openedx.core.djangolib.blockstore_cache import BundleCache
|
||||
from .keys import LibraryLocatorV2, LibraryUsageLocatorV2
|
||||
from .models import ContentLibrary, ContentLibraryPermission
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# This API is only used in Studio, so we always work with this draft of any
|
||||
# content library bundle:
|
||||
DRAFT_NAME = 'studio_draft'
|
||||
|
||||
# Exceptions:
|
||||
ContentLibraryNotFound = ContentLibrary.DoesNotExist
|
||||
|
||||
|
||||
class ContentLibraryBlockNotFound(XBlockNotFoundError):
|
||||
""" XBlock not found in the content library """
|
||||
|
||||
|
||||
class LibraryAlreadyExists(KeyError):
|
||||
""" A library with the specified slug already exists """
|
||||
|
||||
|
||||
class LibraryBlockAlreadyExists(KeyError):
|
||||
""" An XBlock with that ID already exists in the library """
|
||||
|
||||
|
||||
# Models:
|
||||
|
||||
@attr.s
|
||||
class ContentLibraryMetadata(object):
|
||||
"""
|
||||
Class that represents the metadata about a content library.
|
||||
"""
|
||||
key = attr.ib(type=LibraryLocatorV2)
|
||||
bundle_uuid = attr.ib(type=UUID)
|
||||
title = attr.ib("")
|
||||
description = attr.ib("")
|
||||
version = attr.ib(0)
|
||||
has_unpublished_changes = attr.ib(False)
|
||||
# has_unpublished_deletes will be true when the draft version of the library's bundle
|
||||
# contains deletes of any XBlocks that were in the most recently published version
|
||||
has_unpublished_deletes = attr.ib(False)
|
||||
|
||||
|
||||
@attr.s
|
||||
class LibraryXBlockMetadata(object):
|
||||
"""
|
||||
Class that represents the metadata about an XBlock in a content library.
|
||||
"""
|
||||
usage_key = attr.ib(type=LibraryUsageLocatorV2)
|
||||
def_key = attr.ib(type=BundleDefinitionLocator)
|
||||
display_name = attr.ib("")
|
||||
has_unpublished_changes = attr.ib(False)
|
||||
|
||||
|
||||
@attr.s
|
||||
class LibraryXBlockType(object):
|
||||
"""
|
||||
An XBlock type that can be added to a content library
|
||||
"""
|
||||
block_type = attr.ib("")
|
||||
display_name = attr.ib("")
|
||||
|
||||
|
||||
class AccessLevel(object):
|
||||
""" Enum defining library access levels/permissions """
|
||||
ADMIN_LEVEL = ContentLibraryPermission.ADMIN_LEVEL
|
||||
AUTHOR_LEVEL = ContentLibraryPermission.AUTHOR_LEVEL
|
||||
READ_LEVEL = ContentLibraryPermission.READ_LEVEL
|
||||
NO_ACCESS = None
|
||||
|
||||
|
||||
def list_libraries():
|
||||
"""
|
||||
TEMPORARY method for testing. Lists all content libraries.
|
||||
This should be replaced with a method for listing all libraries that belong
|
||||
to a particular user, and/or has permission to view. This method makes at
|
||||
least one HTTP call per library so should only be used for development.
|
||||
"""
|
||||
refs = ContentLibrary.objects.all()[:1000]
|
||||
return [get_library(ref.library_key) for ref in refs]
|
||||
|
||||
|
||||
def get_library(library_key):
|
||||
"""
|
||||
Get the library with the specified key. Does not check permissions.
|
||||
returns a ContentLibraryMetadata instance.
|
||||
|
||||
Raises ContentLibraryNotFound if the library doesn't exist.
|
||||
"""
|
||||
assert isinstance(library_key, LibraryLocatorV2)
|
||||
ref = ContentLibrary.objects.get_by_key(library_key)
|
||||
bundle_metadata = get_bundle(ref.bundle_uuid)
|
||||
lib_bundle = LibraryBundle(library_key, ref.bundle_uuid, draft_name=DRAFT_NAME)
|
||||
(has_unpublished_changes, has_unpublished_deletes) = lib_bundle.has_changes()
|
||||
return ContentLibraryMetadata(
|
||||
key=library_key,
|
||||
bundle_uuid=ref.bundle_uuid,
|
||||
title=bundle_metadata.title,
|
||||
description=bundle_metadata.description,
|
||||
version=bundle_metadata.latest_version,
|
||||
has_unpublished_changes=has_unpublished_changes,
|
||||
has_unpublished_deletes=has_unpublished_deletes,
|
||||
)
|
||||
|
||||
|
||||
def create_library(collection_uuid, org, slug, title, description):
|
||||
"""
|
||||
Create a new content library.
|
||||
|
||||
org: an organizations.models.Organization instance
|
||||
|
||||
slug: a slug for this library like 'physics-problems'
|
||||
|
||||
title: title for this library
|
||||
|
||||
description: description of this library
|
||||
|
||||
Returns a ContentLibraryMetadata instance.
|
||||
"""
|
||||
assert isinstance(collection_uuid, UUID)
|
||||
assert isinstance(org, Organization)
|
||||
validate_unicode_slug(slug)
|
||||
# First, create the blockstore bundle:
|
||||
bundle = create_bundle(
|
||||
collection_uuid,
|
||||
slug=slug,
|
||||
title=title,
|
||||
description=description,
|
||||
)
|
||||
# Now create the library reference in our database:
|
||||
try:
|
||||
ref = ContentLibrary.objects.create(
|
||||
org=org,
|
||||
slug=slug,
|
||||
bundle_uuid=bundle.uuid,
|
||||
allow_public_learning=True,
|
||||
allow_public_read=True,
|
||||
)
|
||||
except IntegrityError:
|
||||
delete_bundle(bundle.uuid)
|
||||
raise LibraryAlreadyExists(slug)
|
||||
return ContentLibraryMetadata(
|
||||
key=ref.library_key,
|
||||
bundle_uuid=bundle.uuid,
|
||||
title=title,
|
||||
description=description,
|
||||
version=0,
|
||||
)
|
||||
|
||||
|
||||
def set_library_user_permissions(library_key, user, access_level):
|
||||
"""
|
||||
Change the specified user's level of access to this library.
|
||||
|
||||
access_level should be one of the AccessLevel values defined above.
|
||||
"""
|
||||
ref = ContentLibrary.objects.get_by_key(library_key)
|
||||
if access_level is None:
|
||||
ref.authorized_users.filter(user=user).delete()
|
||||
else:
|
||||
ContentLibraryPermission.objects.update_or_create(user=user, library=ref, access_level=access_level)
|
||||
|
||||
|
||||
def update_library(library_key, title=None, description=None):
|
||||
"""
|
||||
Update a library's title or description.
|
||||
(Slug cannot be changed as it would break IDs throughout the system.)
|
||||
|
||||
A value of None means "don't change".
|
||||
"""
|
||||
ref = ContentLibrary.objects.get_by_key(library_key)
|
||||
fields = {
|
||||
# We don't ever read the "slug" value from the Blockstore bundle, but
|
||||
# we might as well always do our best to keep it in sync with the "slug"
|
||||
# value in the LMS that we do use.
|
||||
"slug": ref.slug,
|
||||
}
|
||||
if title is not None:
|
||||
assert isinstance(title, six.string_types)
|
||||
fields["title"] = title
|
||||
if description is not None:
|
||||
assert isinstance(description, six.string_types)
|
||||
fields["description"] = description
|
||||
update_bundle(ref.bundle_uuid, **fields)
|
||||
|
||||
|
||||
def delete_library(library_key):
|
||||
"""
|
||||
Delete a content library
|
||||
"""
|
||||
ref = ContentLibrary.objects.get_by_key(library_key)
|
||||
bundle_uuid = ref.bundle_uuid
|
||||
# We can't atomically delete the ref and bundle at the same time.
|
||||
# Delete the ref first, then the bundle. An error may cause the bundle not
|
||||
# to get deleted, but the library will still be effectively gone from the
|
||||
# system, which is a better state than having a reference to a library with
|
||||
# no backing blockstore bundle.
|
||||
ref.delete()
|
||||
try:
|
||||
delete_bundle(bundle_uuid)
|
||||
except:
|
||||
log.exception("Failed to delete blockstore bundle %s when deleting library. Delete it manually.", bundle_uuid)
|
||||
raise
|
||||
|
||||
|
||||
def get_library_blocks(library_key):
|
||||
"""
|
||||
Get the list of top-level XBlocks in the specified library.
|
||||
|
||||
Returns a list of LibraryXBlockMetadata objects
|
||||
"""
|
||||
ref = ContentLibrary.objects.get_by_key(library_key)
|
||||
lib_bundle = LibraryBundle(library_key, ref.bundle_uuid, draft_name=DRAFT_NAME)
|
||||
usages = lib_bundle.get_top_level_usages()
|
||||
blocks = []
|
||||
for usage_key in usages:
|
||||
# For top-level definitions, we can go from definition key to usage key using the following, but this would not
|
||||
# work for non-top-level blocks as they may have multiple usages. Top level blocks are guaranteed to have only
|
||||
# a single usage in the library, which is part of the definition of top level block.
|
||||
def_key = lib_bundle.definition_for_usage(usage_key)
|
||||
blocks.append(LibraryXBlockMetadata(
|
||||
usage_key=usage_key,
|
||||
def_key=def_key,
|
||||
display_name=get_block_display_name(def_key),
|
||||
has_unpublished_changes=lib_bundle.does_definition_have_unpublished_changes(def_key),
|
||||
))
|
||||
return blocks
|
||||
|
||||
|
||||
def get_library_block(usage_key):
|
||||
"""
|
||||
Get metadata (LibraryXBlockMetadata) about one specific XBlock in a library
|
||||
|
||||
To load the actual XBlock instance, use
|
||||
openedx.core.djangoapps.xblock.api.load_block()
|
||||
instead.
|
||||
"""
|
||||
assert isinstance(usage_key, LibraryUsageLocatorV2)
|
||||
lib_context = get_learning_context_impl(usage_key)
|
||||
def_key = lib_context.definition_for_usage(usage_key)
|
||||
if def_key is None:
|
||||
raise ContentLibraryBlockNotFound(usage_key)
|
||||
lib_bundle = LibraryBundle(usage_key.library_slug, def_key.bundle_uuid, draft_name=DRAFT_NAME)
|
||||
return LibraryXBlockMetadata(
|
||||
usage_key=usage_key,
|
||||
def_key=def_key,
|
||||
display_name=get_block_display_name(def_key),
|
||||
has_unpublished_changes=lib_bundle.does_definition_have_unpublished_changes(def_key),
|
||||
)
|
||||
|
||||
|
||||
def get_library_block_olx(usage_key):
|
||||
"""
|
||||
Get the OLX source of the given XBlock.
|
||||
"""
|
||||
assert isinstance(usage_key, LibraryUsageLocatorV2)
|
||||
definition_key = get_library_block(usage_key).def_key
|
||||
xml_str = get_bundle_file_data(
|
||||
bundle_uuid=definition_key.bundle_uuid, # pylint: disable=no-member
|
||||
path=definition_key.olx_path, # pylint: disable=no-member
|
||||
use_draft=DRAFT_NAME,
|
||||
)
|
||||
return xml_str
|
||||
|
||||
|
||||
def set_library_block_olx(usage_key, new_olx_str):
|
||||
"""
|
||||
Replace the OLX source of the given XBlock.
|
||||
This is only meant for use by developers or API client applications, as
|
||||
very little validation is done and this can easily result in a broken XBlock
|
||||
that won't load.
|
||||
"""
|
||||
# because this old pylint can't understand attr.ib() objects, pylint: disable=no-member
|
||||
assert isinstance(usage_key, LibraryUsageLocatorV2)
|
||||
# Make sure the block exists:
|
||||
metadata = get_library_block(usage_key)
|
||||
block_type = usage_key.block_type
|
||||
# Verify that the OLX parses, at least as generic XML:
|
||||
node = etree.fromstring(new_olx_str)
|
||||
if node.tag != block_type:
|
||||
raise ValueError("Invalid root tag in OLX, expected {}".format(block_type))
|
||||
# Write the new XML/OLX file into the library bundle's draft
|
||||
draft = get_or_create_bundle_draft(metadata.def_key.bundle_uuid, DRAFT_NAME)
|
||||
write_draft_file(draft.uuid, metadata.def_key.olx_path, new_olx_str)
|
||||
# Clear the bundle cache so everyone sees the new block immediately:
|
||||
BundleCache(metadata.def_key.bundle_uuid, draft_name=DRAFT_NAME).clear()
|
||||
|
||||
|
||||
def create_library_block(library_key, block_type, definition_id):
|
||||
"""
|
||||
Create a new XBlock in this library of the specified type (e.g. "html").
|
||||
|
||||
The 'definition_id' value (which should be a string like "problem1") will be
|
||||
used as both the definition_id and the usage_id.
|
||||
"""
|
||||
assert isinstance(library_key, LibraryLocatorV2)
|
||||
ref = ContentLibrary.objects.get_by_key(library_key)
|
||||
# Make sure the proposed ID will be valid:
|
||||
validate_unicode_slug(definition_id)
|
||||
# Ensure the XBlock type is valid and installed:
|
||||
XBlock.load_class(block_type) # Will raise an exception if invalid
|
||||
# Make sure the new ID is not taken already:
|
||||
new_usage_id = definition_id # Since this is a top level XBlock, usage_id == definition_id
|
||||
usage_key = LibraryUsageLocatorV2(
|
||||
library_org=library_key.org,
|
||||
library_slug=library_key.slug,
|
||||
block_type=block_type,
|
||||
usage_id=new_usage_id,
|
||||
)
|
||||
library_context = get_learning_context_impl(usage_key)
|
||||
if library_context.definition_for_usage(usage_key) is not None:
|
||||
raise LibraryBlockAlreadyExists("An XBlock with ID '{}' already exists".format(new_usage_id))
|
||||
|
||||
new_definition_xml = '<{}/>'.format(block_type) # xss-lint: disable=python-wrap-html
|
||||
path = "{}/{}/definition.xml".format(block_type, definition_id)
|
||||
# Write the new XML/OLX file into the library bundle's draft
|
||||
draft = get_or_create_bundle_draft(ref.bundle_uuid, DRAFT_NAME)
|
||||
write_draft_file(draft.uuid, path, new_definition_xml)
|
||||
# Clear the bundle cache so everyone sees the new block immediately:
|
||||
BundleCache(ref.bundle_uuid, draft_name=DRAFT_NAME).clear()
|
||||
# Now return the metadata about the new block:
|
||||
return get_library_block(usage_key)
|
||||
|
||||
|
||||
def delete_library_block(usage_key, remove_from_parent=True):
|
||||
"""
|
||||
Delete the specified block from this library (and any children it has).
|
||||
|
||||
If the block's definition (OLX file) is within this same library as the
|
||||
usage key, both the definition and the usage will be deleted.
|
||||
|
||||
If the usage points to a definition in a linked bundle, the usage will be
|
||||
deleted but the link and the linked bundle will be unaffected.
|
||||
|
||||
If the block is in use by some other bundle that links to this one, that
|
||||
will not prevent deletion of the definition.
|
||||
|
||||
remove_from_parent: modify the parent to remove the reference to this
|
||||
delete block. This should always be true except when this function
|
||||
calls itself recursively.
|
||||
"""
|
||||
assert isinstance(usage_key, LibraryUsageLocatorV2)
|
||||
library_context = get_learning_context_impl(usage_key)
|
||||
library_ref = ContentLibrary.objects.get_by_key(usage_key.context_key)
|
||||
def_key = library_context.definition_for_usage(usage_key)
|
||||
if def_key is None:
|
||||
raise ContentLibraryBlockNotFound(usage_key)
|
||||
lib_bundle = LibraryBundle(usage_key.context_key, library_ref.bundle_uuid, draft_name=DRAFT_NAME)
|
||||
# Create a draft:
|
||||
draft_uuid = get_or_create_bundle_draft(def_key.bundle_uuid, DRAFT_NAME).uuid
|
||||
# Does this block have a parent?
|
||||
if usage_key not in lib_bundle.get_top_level_usages() and remove_from_parent:
|
||||
# Yes: this is not a top-level block.
|
||||
# First need to modify the parent to remove this block as a child.
|
||||
raise NotImplementedError
|
||||
# Does this block have children?
|
||||
block = load_block(usage_key, user=None)
|
||||
if block.has_children:
|
||||
# Next, recursively call delete_library_block(...) on each child usage
|
||||
for child_usage in block.children:
|
||||
# Specify remove_from_parent=False to avoid unnecessary work to
|
||||
# modify this block's children list when deleting each child, since
|
||||
# we're going to delete this block anyways.
|
||||
delete_library_block(child_usage, remove_from_parent=False)
|
||||
# Delete the definition:
|
||||
if def_key.bundle_uuid == library_ref.bundle_uuid:
|
||||
# This definition is in the library, so delete it:
|
||||
path_prefix = lib_bundle.olx_prefix(def_key)
|
||||
for bundle_file in get_bundle_files(def_key.bundle_uuid, use_draft=DRAFT_NAME):
|
||||
if bundle_file.path.startswith(path_prefix):
|
||||
# Delete this file, within this definition's "folder"
|
||||
write_draft_file(draft_uuid, bundle_file.path, contents=None)
|
||||
else:
|
||||
# The definition must be in a linked bundle, so we don't want to delete
|
||||
# it; just the <xblock-include /> in the parent, which was already
|
||||
# deleted above.
|
||||
pass
|
||||
# Clear the bundle cache so everyone sees the deleted block immediately:
|
||||
lib_bundle.cache.clear()
|
||||
|
||||
|
||||
def create_library_block_child(parent_usage_key, block_type, definition_id):
|
||||
"""
|
||||
Create a new XBlock definition in this library of the specified type (e.g.
|
||||
"html"), and add it as a child of the specified existing block.
|
||||
|
||||
The 'definition_id' value (which should be a string like "problem1") will be
|
||||
used as both the definition_id and the usage_id of the child.
|
||||
"""
|
||||
assert isinstance(parent_usage_key, LibraryUsageLocatorV2)
|
||||
# Load the parent block to make sure it exists and so we can modify its 'children' field:
|
||||
parent_block = load_block(parent_usage_key, user=None)
|
||||
if not parent_block.has_children:
|
||||
raise ValueError("The specified parent XBlock does not allow child XBlocks.")
|
||||
# Create the new block in the library:
|
||||
metadata = create_library_block(parent_usage_key.context_key, block_type, definition_id)
|
||||
# Set the block as a child.
|
||||
# This will effectively "move" the newly created block from being a top-level block in the library to a child.
|
||||
include_data = XBlockInclude(link_id=None, block_type=block_type, definition_id=definition_id, usage_hint=None)
|
||||
parent_block.runtime.add_child_include(parent_block, include_data)
|
||||
parent_block.save()
|
||||
return metadata
|
||||
|
||||
|
||||
def get_allowed_block_types(library_key): # pylint: disable=unused-argument
|
||||
"""
|
||||
Get a list of XBlock types that can be added to the specified content
|
||||
library. For now, the result is the same regardless of which library is
|
||||
specified, but that may change in the future.
|
||||
"""
|
||||
# TODO: return support status and template options
|
||||
# See cms/djangoapps/contentstore/views/component.py
|
||||
block_types = sorted(name for name, class_ in XBlock.load_classes())
|
||||
info = []
|
||||
for block_type in block_types:
|
||||
display_name = xblock_type_display_name(block_type, None)
|
||||
# For now as a crude heuristic, we exclude blocks that don't have a display_name
|
||||
if display_name:
|
||||
info.append(LibraryXBlockType(block_type=block_type, display_name=display_name))
|
||||
return info
|
||||
|
||||
|
||||
def publish_changes(library_key):
|
||||
"""
|
||||
Publish all pending changes to the specified library.
|
||||
"""
|
||||
ref = ContentLibrary.objects.get_by_key(library_key)
|
||||
bundle = get_bundle(ref.bundle_uuid)
|
||||
if DRAFT_NAME in bundle.drafts: # pylint: disable=unsupported-membership-test
|
||||
draft_uuid = bundle.drafts[DRAFT_NAME] # pylint: disable=unsubscriptable-object
|
||||
commit_draft(draft_uuid)
|
||||
else:
|
||||
return # If there is no draft, no action is needed.
|
||||
LibraryBundle(library_key, ref.bundle_uuid).cache.clear()
|
||||
LibraryBundle(library_key, ref.bundle_uuid, draft_name=DRAFT_NAME).cache.clear()
|
||||
|
||||
|
||||
def revert_changes(library_key):
|
||||
"""
|
||||
Revert all pending changes to the specified library, restoring it to the
|
||||
last published version.
|
||||
"""
|
||||
ref = ContentLibrary.objects.get_by_key(library_key)
|
||||
bundle = get_bundle(ref.bundle_uuid)
|
||||
if DRAFT_NAME in bundle.drafts: # pylint: disable=unsupported-membership-test
|
||||
draft_uuid = bundle.drafts[DRAFT_NAME] # pylint: disable=unsubscriptable-object
|
||||
delete_draft(draft_uuid)
|
||||
else:
|
||||
return # If there is no draft, no action is needed.
|
||||
LibraryBundle(library_key, ref.bundle_uuid, draft_name=DRAFT_NAME).cache.clear()
|
||||
32
openedx/core/djangoapps/content_libraries/apps.py
Normal file
32
openedx/core/djangoapps/content_libraries/apps.py
Normal file
@@ -0,0 +1,32 @@
|
||||
"""
|
||||
Django AppConfig for Content Libraries Implementation
|
||||
"""
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import absolute_import, division, print_function, unicode_literals
|
||||
|
||||
from django.apps import AppConfig
|
||||
|
||||
from openedx.core.djangoapps.plugins.constants import ProjectType, PluginURLs, PluginSettings
|
||||
|
||||
|
||||
class ContentLibrariesConfig(AppConfig):
|
||||
"""
|
||||
Django AppConfig for Content Libraries Implementation
|
||||
"""
|
||||
|
||||
name = 'openedx.core.djangoapps.content_libraries'
|
||||
verbose_name = 'Content Libraries (Blockstore-based)'
|
||||
# This is designed as a plugin for now so that
|
||||
# the whole thing is self-contained and can easily be enabled/disabled
|
||||
plugin_app = {
|
||||
PluginURLs.CONFIG: {
|
||||
ProjectType.CMS: {
|
||||
# The namespace to provide to django's urls.include.
|
||||
PluginURLs.NAMESPACE: u'content_libraries',
|
||||
},
|
||||
},
|
||||
PluginSettings.CONFIG: {
|
||||
ProjectType.CMS: {
|
||||
},
|
||||
},
|
||||
}
|
||||
132
openedx/core/djangoapps/content_libraries/keys.py
Normal file
132
openedx/core/djangoapps/content_libraries/keys.py
Normal file
@@ -0,0 +1,132 @@
|
||||
"""
|
||||
Key/locator types for Blockstore-based content libraries
|
||||
"""
|
||||
# Disable warnings about _to_deprecated_string etc. which we don't want to implement:
|
||||
# pylint: disable=abstract-method, no-member
|
||||
from __future__ import absolute_import, division, print_function, unicode_literals
|
||||
|
||||
from opaque_keys import InvalidKeyError
|
||||
|
||||
from openedx.core.djangoapps.xblock.learning_context.keys import (
|
||||
check_key_string_field,
|
||||
BlockUsageKeyV2,
|
||||
LearningContextKey,
|
||||
)
|
||||
|
||||
|
||||
class LibraryLocatorV2(LearningContextKey):
|
||||
"""
|
||||
A key that represents a Blockstore-based content library.
|
||||
|
||||
When serialized, these keys look like:
|
||||
lib:MITx:reallyhardproblems
|
||||
lib:hogwarts:p300-potions-exercises
|
||||
"""
|
||||
CANONICAL_NAMESPACE = 'lib'
|
||||
KEY_FIELDS = ('org', 'slug')
|
||||
__slots__ = KEY_FIELDS
|
||||
CHECKED_INIT = False
|
||||
|
||||
def __init__(self, org, slug):
|
||||
"""
|
||||
Construct a GlobalUsageLocator
|
||||
"""
|
||||
check_key_string_field(org)
|
||||
check_key_string_field(slug)
|
||||
super(LibraryLocatorV2, self).__init__(org=org, slug=slug)
|
||||
|
||||
def _to_string(self):
|
||||
"""
|
||||
Serialize this key as a string
|
||||
"""
|
||||
return ":".join((self.org, self.slug))
|
||||
|
||||
@classmethod
|
||||
def _from_string(cls, serialized):
|
||||
"""
|
||||
Instantiate this key from a serialized string
|
||||
"""
|
||||
try:
|
||||
(org, slug) = serialized.split(':')
|
||||
except ValueError:
|
||||
raise InvalidKeyError(cls, serialized)
|
||||
return cls(org=org, slug=slug)
|
||||
|
||||
def make_definition_usage(self, definition_key, usage_id=None):
|
||||
"""
|
||||
Return a usage key, given the given the specified definition key and
|
||||
usage_id.
|
||||
"""
|
||||
return LibraryUsageLocatorV2(
|
||||
library_org=self.org,
|
||||
library_slug=self.slug,
|
||||
block_type=definition_key.block_type,
|
||||
usage_id=usage_id,
|
||||
)
|
||||
|
||||
def for_branch(self, branch):
|
||||
"""
|
||||
Compatibility helper.
|
||||
Some code calls .for_branch(None) on course keys. By implementing this,
|
||||
it improves backwards compatibility between library keys and course
|
||||
keys.
|
||||
"""
|
||||
if branch is not None:
|
||||
raise ValueError("Cannot call for_branch on a content library key, except for_branch(None).")
|
||||
return self
|
||||
|
||||
|
||||
class LibraryUsageLocatorV2(BlockUsageKeyV2):
|
||||
"""
|
||||
An XBlock in a Blockstore-based content library.
|
||||
|
||||
When serialized, these keys look like:
|
||||
lb:MITx:reallyhardproblems:problem:problem1
|
||||
"""
|
||||
CANONICAL_NAMESPACE = 'lb' # "Library Block"
|
||||
KEY_FIELDS = ('library_org', 'library_slug', 'block_type', 'usage_id')
|
||||
__slots__ = KEY_FIELDS
|
||||
CHECKED_INIT = False
|
||||
|
||||
def __init__(self, library_org, library_slug, block_type, usage_id):
|
||||
"""
|
||||
Construct a LibraryUsageLocatorV2
|
||||
"""
|
||||
check_key_string_field(library_org)
|
||||
check_key_string_field(library_slug)
|
||||
check_key_string_field(block_type)
|
||||
check_key_string_field(usage_id)
|
||||
super(LibraryUsageLocatorV2, self).__init__(
|
||||
library_org=library_org,
|
||||
library_slug=library_slug,
|
||||
block_type=block_type,
|
||||
usage_id=usage_id,
|
||||
)
|
||||
|
||||
@property
|
||||
def context_key(self):
|
||||
return LibraryLocatorV2(org=self.library_org, slug=self.library_slug)
|
||||
|
||||
@property
|
||||
def block_id(self):
|
||||
"""
|
||||
Get the 'block ID' which is another name for the usage ID.
|
||||
"""
|
||||
return self.usage_id
|
||||
|
||||
def _to_string(self):
|
||||
"""
|
||||
Serialize this key as a string
|
||||
"""
|
||||
return ":".join((self.library_org, self.library_slug, self.block_type, self.usage_id))
|
||||
|
||||
@classmethod
|
||||
def _from_string(cls, serialized):
|
||||
"""
|
||||
Instantiate this key from a serialized string
|
||||
"""
|
||||
try:
|
||||
(library_org, library_slug, block_type, usage_id) = serialized.split(':')
|
||||
except ValueError:
|
||||
raise InvalidKeyError(cls, serialized)
|
||||
return cls(library_org=library_org, library_slug=library_slug, block_type=block_type, usage_id=usage_id)
|
||||
346
openedx/core/djangoapps/content_libraries/library_bundle.py
Normal file
346
openedx/core/djangoapps/content_libraries/library_bundle.py
Normal file
@@ -0,0 +1,346 @@
|
||||
"""
|
||||
Helper code for working with Blockstore bundles that contain OLX
|
||||
"""
|
||||
from __future__ import absolute_import, division, print_function, unicode_literals
|
||||
import logging
|
||||
|
||||
from django.utils.lru_cache import lru_cache
|
||||
from xblock.core import XBlock
|
||||
from xblock.plugin import PluginMissingError
|
||||
|
||||
from openedx.core.djangoapps.content_libraries.keys import LibraryUsageLocatorV2
|
||||
from openedx.core.djangoapps.content_libraries.models import ContentLibrary
|
||||
from openedx.core.djangoapps.xblock.learning_context.keys import BundleDefinitionLocator
|
||||
from openedx.core.djangoapps.xblock.runtime.blockstore_runtime import xml_for_definition
|
||||
from openedx.core.djangoapps.xblock.runtime.olx_parsing import (
|
||||
BundleFormatException,
|
||||
definition_for_include,
|
||||
parse_xblock_include,
|
||||
)
|
||||
from openedx.core.djangolib.blockstore_cache import (
|
||||
BundleCache,
|
||||
get_bundle_files_cached,
|
||||
get_bundle_file_metadata_with_cache,
|
||||
get_bundle_version_number,
|
||||
)
|
||||
from openedx.core.lib import blockstore_api
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@lru_cache()
|
||||
def bundle_uuid_for_library_key(library_key):
|
||||
"""
|
||||
Given a library slug, look up its bundle UUID.
|
||||
Can be cached aggressively since bundle UUID is immutable.
|
||||
|
||||
May raise ContentLibrary.DoesNotExist
|
||||
"""
|
||||
library_metadata = ContentLibrary.objects.get_by_key(library_key)
|
||||
return library_metadata.bundle_uuid
|
||||
|
||||
|
||||
def usage_for_child_include(parent_usage, parent_definition, parsed_include):
|
||||
"""
|
||||
Get the usage ID for a child XBlock, given the parent's keys and the
|
||||
<xblock-include /> element that specifies the child.
|
||||
|
||||
Consider two bundles, one with three definitions:
|
||||
main-unit, html1, subunit1
|
||||
And a second bundle with two definitions:
|
||||
unit1, html1
|
||||
Note that both bundles have a definition called "html1". Now, with the
|
||||
following tree structure, where "unit/unit1" and the second "html/html1"
|
||||
are in a linked bundle:
|
||||
|
||||
<unit> in unit/main-unit/definition.xml
|
||||
<xblock-include definition="html/html1" />
|
||||
<xblock-include definition="unit/subunit1" />
|
||||
<xblock-include source="linked_bundle" definition="unit/unit1" usage="alias1" />
|
||||
<xblock-include definition="html/html1" />
|
||||
|
||||
The following usage IDs would result:
|
||||
|
||||
main-unit
|
||||
html1
|
||||
subunit1
|
||||
alias1
|
||||
alias1-html1
|
||||
|
||||
Notice that "html1" in the linked bundle is prefixed so its ID stays
|
||||
unique from the "html1" in the original library.
|
||||
"""
|
||||
assert isinstance(parent_usage, LibraryUsageLocatorV2)
|
||||
usage_id = parsed_include.usage_hint if parsed_include.usage_hint else parsed_include.definition_id
|
||||
library_bundle_uuid = bundle_uuid_for_library_key(parent_usage.context_key)
|
||||
# Is the parent usage from the same bundle as the library?
|
||||
parent_usage_from_library_bundle = parent_definition.bundle_uuid == library_bundle_uuid
|
||||
if not parent_usage_from_library_bundle:
|
||||
# This XBlock has been linked in to the library via a chain of one
|
||||
# or more bundle links. In order to keep usage_id collisions from
|
||||
# happening, any descdenants of the first linked block must have
|
||||
# their usage_id prefixed with the parent usage's usage_id.
|
||||
# (It would be possible to only change the prefix when the block is
|
||||
# a child of a block with an explicit usage="" attribute on its
|
||||
# <xblock-include> but that requires much more complex logic.)
|
||||
usage_id = parent_usage.usage_id + "-" + usage_id
|
||||
return LibraryUsageLocatorV2(
|
||||
library_org=parent_usage.library_org,
|
||||
library_slug=parent_usage.library_slug,
|
||||
block_type=parsed_include.block_type,
|
||||
usage_id=usage_id,
|
||||
)
|
||||
|
||||
|
||||
class LibraryBundle(object):
|
||||
"""
|
||||
Wrapper around a Content Library Blockstore bundle that contains OLX.
|
||||
"""
|
||||
|
||||
def __init__(self, library_key, bundle_uuid, draft_name=None):
|
||||
"""
|
||||
Instantiate this wrapper for the bundle with the specified library_key,
|
||||
UUID, and optionally the specified draft name.
|
||||
"""
|
||||
self.library_key = library_key
|
||||
self.bundle_uuid = bundle_uuid
|
||||
self.draft_name = draft_name
|
||||
self.cache = BundleCache(bundle_uuid, draft_name)
|
||||
|
||||
def get_olx_files(self):
|
||||
"""
|
||||
Get the list of OLX files in this bundle (using a heuristic)
|
||||
|
||||
Because this uses a heuristic, it will only return files with filenames
|
||||
that seem like OLX files that are in the expected locations of OLX
|
||||
files. They are not guaranteed to be valid OLX nor will OLX files in
|
||||
nonstandard locations be returned.
|
||||
|
||||
Example return value: [
|
||||
'html/intro/definition.xml',
|
||||
'unit/unit1/definition.xml',
|
||||
]
|
||||
"""
|
||||
bundle_files = get_bundle_files_cached(self.bundle_uuid, draft_name=self.draft_name)
|
||||
return [f.path for f in bundle_files if f.path.endswith("/definition.xml")]
|
||||
|
||||
def definition_for_usage(self, usage_key):
|
||||
"""
|
||||
Given the usage key for an XBlock in this library bundle, return the
|
||||
BundleDefinitionLocator which specifies the actual XBlock definition (as
|
||||
a path to an OLX in a specific blockstore bundle).
|
||||
|
||||
Must return a BundleDefinitionLocator if the XBlock exists in this
|
||||
context, or None otherwise.
|
||||
|
||||
For a content library, the rules are simple:
|
||||
* If the usage key points to a block in this library, the filename
|
||||
(definition) of the OLX file is always
|
||||
{block_type}/{usage_id}/definition.xml
|
||||
Each library has exactly one usage per definition for its own blocks.
|
||||
* However, block definitions from other content libraries may be linked
|
||||
into this library via <xblock-include ... /> directives. In that case,
|
||||
it's necessary to inspect every OLX file in this library that might
|
||||
have an <xblock-include /> directive in order to find what external
|
||||
block the usage ID refers to.
|
||||
"""
|
||||
# Now that we know the library/bundle, find the block's definition
|
||||
if self.draft_name:
|
||||
version_arg = {"draft_name": self.draft_name}
|
||||
else:
|
||||
version_arg = {"bundle_version": get_bundle_version_number(self.bundle_uuid)}
|
||||
olx_path = "{}/{}/definition.xml".format(usage_key.block_type, usage_key.usage_id)
|
||||
try:
|
||||
get_bundle_file_metadata_with_cache(self.bundle_uuid, olx_path, **version_arg)
|
||||
return BundleDefinitionLocator(self.bundle_uuid, usage_key.block_type, olx_path, **version_arg)
|
||||
except blockstore_api.BundleFileNotFound:
|
||||
# This must be a usage of a block from a linked bundle. One of the
|
||||
# OLX files in this bundle contains an <xblock-include usage="..."/>
|
||||
bundle_includes = self.get_bundle_includes()
|
||||
try:
|
||||
return bundle_includes[usage_key]
|
||||
except KeyError:
|
||||
return None
|
||||
|
||||
def get_top_level_usages(self):
|
||||
"""
|
||||
Get the set of usage keys in this bundle that have no parent.
|
||||
"""
|
||||
own_usage_keys = []
|
||||
for olx_file_path in self.get_olx_files():
|
||||
block_type, usage_id, _unused = olx_file_path.split('/')
|
||||
usage_key = LibraryUsageLocatorV2(self.library_key.org, self.library_key.slug, block_type, usage_id)
|
||||
own_usage_keys.append(usage_key)
|
||||
|
||||
usage_keys_with_parents = self.get_bundle_includes().keys()
|
||||
return [usage_key for usage_key in own_usage_keys if usage_key not in usage_keys_with_parents]
|
||||
|
||||
def get_bundle_includes(self):
|
||||
"""
|
||||
Scan through the bundle and all linked bundles as needed to generate
|
||||
a complete list of all the blocks that are included as
|
||||
child/grandchild/... blocks of the blocks in this bundle.
|
||||
|
||||
Returns a dict of {usage_key -> BundleDefinitionLocator}
|
||||
|
||||
Blocks in the bundle that have no parent are not included.
|
||||
"""
|
||||
cache_key = ("bundle_includes", )
|
||||
usages_found = self.cache.get(cache_key)
|
||||
if usages_found is not None:
|
||||
return usages_found
|
||||
|
||||
usages_found = {}
|
||||
|
||||
def add_definitions_children(usage_key, def_key):
|
||||
"""
|
||||
Recursively add any children of the given XBlock usage+definition to
|
||||
usages_found.
|
||||
"""
|
||||
if not does_block_type_support_children(def_key.block_type):
|
||||
return
|
||||
try:
|
||||
xml_node = xml_for_definition(def_key)
|
||||
except: # pylint:disable=bare-except
|
||||
log.exception("Unable to load definition {}".format(def_key))
|
||||
return
|
||||
|
||||
for child in xml_node:
|
||||
if child.tag != 'xblock-include':
|
||||
continue
|
||||
try:
|
||||
parsed_include = parse_xblock_include(child)
|
||||
child_usage = usage_for_child_include(usage_key, def_key, parsed_include)
|
||||
child_def_key = definition_for_include(parsed_include, def_key)
|
||||
except BundleFormatException:
|
||||
log.exception("Unable to parse a child of {}".format(def_key))
|
||||
continue
|
||||
usages_found[child_usage] = child_def_key
|
||||
add_definitions_children(child_usage, child_def_key)
|
||||
|
||||
# Find all the definitions in this bundle and recursively add all their descendants:
|
||||
bundle_files = get_bundle_files_cached(self.bundle_uuid, draft_name=self.draft_name)
|
||||
if self.draft_name:
|
||||
version_arg = {"draft_name": self.draft_name}
|
||||
else:
|
||||
version_arg = {"bundle_version": get_bundle_version_number(self.bundle_uuid)}
|
||||
for bfile in bundle_files:
|
||||
if not bfile.path.endswith("/definition.xml") or bfile.path.count('/') != 2:
|
||||
continue # Not an OLX file.
|
||||
block_type, usage_id, _unused = bfile.path.split('/')
|
||||
def_key = BundleDefinitionLocator(
|
||||
bundle_uuid=self.bundle_uuid,
|
||||
block_type=block_type,
|
||||
olx_path=bfile.path,
|
||||
**version_arg
|
||||
)
|
||||
usage_key = LibraryUsageLocatorV2(self.library_key.org, self.library_key.slug, block_type, usage_id)
|
||||
add_definitions_children(usage_key, def_key)
|
||||
|
||||
self.cache.set(cache_key, usages_found)
|
||||
return usages_found
|
||||
|
||||
def does_definition_have_unpublished_changes(self, definition_key):
|
||||
"""
|
||||
Given the defnition key of an XBlock, which exists in an OLX file like
|
||||
problem/quiz1/definition.xml
|
||||
Check if the bundle's draft has _any_ unpublished changes in the
|
||||
problem/quiz1/
|
||||
directory.
|
||||
"""
|
||||
if self.draft_name is None:
|
||||
return False # No active draft so can't be changes
|
||||
prefix = self.olx_prefix(definition_key)
|
||||
return prefix in self._get_changed_definitions()
|
||||
|
||||
def _get_changed_definitions(self):
|
||||
"""
|
||||
Helper method to get a list of all paths with changes, where a path is
|
||||
problem/quiz1/
|
||||
Or similar (a type and an ID), excluding 'definition.xml'
|
||||
"""
|
||||
cached_result = self.cache.get(('changed_definition_prefixes', ))
|
||||
if cached_result is not None:
|
||||
return cached_result
|
||||
changed = []
|
||||
bundle_files = get_bundle_files_cached(self.bundle_uuid, draft_name=self.draft_name)
|
||||
for file_ in bundle_files:
|
||||
if getattr(file_, 'modified', False) and file_.path.count('/') >= 2:
|
||||
(type_part, id_part, _rest) = file_.path.split('/', 2)
|
||||
prefix = type_part + '/' + id_part + '/'
|
||||
if prefix not in changed:
|
||||
changed.append(prefix)
|
||||
self.cache.set(('changed_definition_prefixes', ), changed)
|
||||
return changed
|
||||
|
||||
def has_changes(self):
|
||||
"""
|
||||
Helper method to check if this OLX bundle has any pending changes,
|
||||
including any deleted blocks.
|
||||
|
||||
Returns a tuple of (
|
||||
has_unpublished_changes,
|
||||
has_unpublished_deletes,
|
||||
)
|
||||
Where has_unpublished_changes is true if there is any type of change,
|
||||
including deletes, and has_unpublished_deletes is only true if one or
|
||||
more blocks has been deleted since the last publish.
|
||||
"""
|
||||
if not self.draft_name:
|
||||
return (False, False)
|
||||
cached_result = self.cache.get(('has_changes', ))
|
||||
if cached_result is not None:
|
||||
return cached_result
|
||||
draft_files = get_bundle_files_cached(self.bundle_uuid, draft_name=self.draft_name)
|
||||
|
||||
has_unpublished_changes = False
|
||||
has_unpublished_deletes = False
|
||||
|
||||
for file_ in draft_files:
|
||||
if getattr(file_, 'modified', False):
|
||||
has_unpublished_changes = True
|
||||
break
|
||||
|
||||
published_file_paths = set(f.path for f in get_bundle_files_cached(self.bundle_uuid))
|
||||
draft_file_paths = set(f.path for f in draft_files)
|
||||
for file_path in published_file_paths:
|
||||
if file_path not in draft_file_paths:
|
||||
has_unpublished_changes = True
|
||||
if file_path.endswith('/definition.xml'):
|
||||
# only set 'has_unpublished_deletes' if the actual main definition XML
|
||||
# file was deleted, not if only some asset file was deleted, etc.
|
||||
has_unpublished_deletes = True
|
||||
break
|
||||
|
||||
result = (has_unpublished_changes, has_unpublished_deletes)
|
||||
self.cache.set(('has_changes', ), result)
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def olx_prefix(definition_key):
|
||||
"""
|
||||
Given a definition key in a compatible bundle, whose olx_path refers to
|
||||
block_type/some_id/definition.xml
|
||||
Return the "folder name" / "path prefix"
|
||||
block-type/some_id/
|
||||
|
||||
This method is here rather than a method of BundleDefinitionLocator
|
||||
because BundleDefinitionLocator is more generic and doesn't require
|
||||
that its olx_path always ends in /definition.xml
|
||||
"""
|
||||
if not definition_key.olx_path.endswith('/definition.xml'):
|
||||
raise ValueError
|
||||
return definition_key.olx_path[:-14] # Remove 'definition.xml', keep trailing slash
|
||||
|
||||
|
||||
def does_block_type_support_children(block_type):
|
||||
"""
|
||||
Does the specified block type (e.g. "html", "vertical") support child
|
||||
blocks?
|
||||
"""
|
||||
try:
|
||||
return XBlock.load_class(block_type).has_children
|
||||
except PluginMissingError:
|
||||
# We don't know if this now-uninstalled block type had children
|
||||
# but to be conservative, assume it may have.
|
||||
return True
|
||||
82
openedx/core/djangoapps/content_libraries/library_context.py
Normal file
82
openedx/core/djangoapps/content_libraries/library_context.py
Normal file
@@ -0,0 +1,82 @@
|
||||
"""
|
||||
Definition of "Library" as a learning context.
|
||||
"""
|
||||
from __future__ import absolute_import, division, print_function, unicode_literals
|
||||
import logging
|
||||
|
||||
from openedx.core.djangoapps.content_libraries.library_bundle import (
|
||||
LibraryBundle,
|
||||
bundle_uuid_for_library_key,
|
||||
usage_for_child_include,
|
||||
)
|
||||
from openedx.core.djangoapps.content_libraries.models import ContentLibrary
|
||||
from openedx.core.djangoapps.xblock.learning_context import LearningContext
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LibraryContextImpl(LearningContext):
|
||||
"""
|
||||
Implements content libraries as a learning context.
|
||||
|
||||
This is the *new* content libraries based on Blockstore, not the old content
|
||||
libraries based on modulestore.
|
||||
"""
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super(LibraryContextImpl, self).__init__(**kwargs)
|
||||
self.use_draft = kwargs.get('use_draft', None)
|
||||
|
||||
def can_edit_block(self, user, usage_key):
|
||||
"""
|
||||
Does the specified usage key exist in its context, and if so, does the
|
||||
specified user (which may be an AnonymousUser) have permission to edit
|
||||
it?
|
||||
|
||||
Must return a boolean.
|
||||
"""
|
||||
def_key = self.definition_for_usage(usage_key)
|
||||
if not def_key:
|
||||
return False
|
||||
# TODO: implement permissions
|
||||
return True
|
||||
|
||||
def can_view_block(self, user, usage_key):
|
||||
"""
|
||||
Does the specified usage key exist in its context, and if so, does the
|
||||
specified user (which may be an AnonymousUser) have permission to view
|
||||
it and interact with it (call handlers, save user state, etc.)?
|
||||
|
||||
Must return a boolean.
|
||||
"""
|
||||
def_key = self.definition_for_usage(usage_key)
|
||||
if not def_key:
|
||||
return False
|
||||
# TODO: implement permissions
|
||||
return True
|
||||
|
||||
def definition_for_usage(self, usage_key):
|
||||
"""
|
||||
Given a usage key for an XBlock in this context, return the
|
||||
BundleDefinitionLocator which specifies the actual XBlock definition
|
||||
(as a path to an OLX in a specific blockstore bundle).
|
||||
|
||||
Must return a BundleDefinitionLocator if the XBlock exists in this
|
||||
context, or None otherwise.
|
||||
"""
|
||||
library_key = usage_key.context_key
|
||||
try:
|
||||
bundle_uuid = bundle_uuid_for_library_key(library_key)
|
||||
except ContentLibrary.DoesNotExist:
|
||||
return None
|
||||
bundle = LibraryBundle(library_key, bundle_uuid, self.use_draft)
|
||||
return bundle.definition_for_usage(usage_key)
|
||||
|
||||
def usage_for_child_include(self, parent_usage, parent_definition, parsed_include):
|
||||
"""
|
||||
Method that the runtime uses when loading a block's child, to get the
|
||||
ID of the child.
|
||||
|
||||
The child is always from an <xblock-include /> element.
|
||||
"""
|
||||
return usage_for_child_include(parent_usage, parent_definition, parsed_include)
|
||||
@@ -0,0 +1,56 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.11.23 on 2019-08-28 20:27
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
('organizations', '0007_historicalorganization'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='ContentLibrary',
|
||||
fields=[
|
||||
('id', models.AutoField(primary_key=True, serialize=False)),
|
||||
('slug', models.SlugField()),
|
||||
('bundle_uuid', models.UUIDField(unique=True)),
|
||||
('allow_public_learning', models.BooleanField(default=False, help_text='\n Allow any user (even unregistered users) to view and interact with\n content in this library (in the LMS; not in Studio). If this is not\n enabled, then the content in this library is not directly accessible\n in the LMS, and learners will only ever see this content if it is\n explicitly added to a course. If in doubt, leave this unchecked.\n ')),
|
||||
('allow_public_read', models.BooleanField(default=False, help_text="\n Allow any user with Studio access to view this library's content in\n Studio, use it in their courses, and copy content out of this\n library. If in doubt, leave this unchecked.\n ")),
|
||||
],
|
||||
options={
|
||||
'verbose_name_plural': 'Content Libraries',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ContentLibraryPermission',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('access_level', models.CharField(choices=[('admin', 'Administer users and author content'), ('author', 'Author content'), ('read', 'Read-only')], max_length=30)),
|
||||
('library', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='content_libraries.ContentLibrary')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='contentlibrary',
|
||||
name='authorized_users',
|
||||
field=models.ManyToManyField(through='content_libraries.ContentLibraryPermission', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='contentlibrary',
|
||||
name='org',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='organizations.Organization'),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='contentlibrary',
|
||||
unique_together=set([('org', 'slug')]),
|
||||
),
|
||||
]
|
||||
107
openedx/core/djangoapps/content_libraries/models.py
Normal file
107
openedx/core/djangoapps/content_libraries/models.py
Normal file
@@ -0,0 +1,107 @@
|
||||
"""
|
||||
Models for new Content Libraries
|
||||
"""
|
||||
from __future__ import absolute_import, division, print_function, unicode_literals
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.db import models
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from organizations.models import Organization
|
||||
import six
|
||||
|
||||
from openedx.core.djangoapps.content_libraries.keys import LibraryLocatorV2
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class ContentLibraryManager(models.Manager):
|
||||
"""
|
||||
Custom manager for ContentLibrary class.
|
||||
"""
|
||||
def get_by_key(self, library_key):
|
||||
"""
|
||||
Get the ContentLibrary for the given LibraryLocatorV2 key.
|
||||
"""
|
||||
assert isinstance(library_key, LibraryLocatorV2)
|
||||
return self.get(org__short_name=library_key.org, slug=library_key.slug)
|
||||
|
||||
|
||||
@six.python_2_unicode_compatible # pylint: disable=model-missing-unicode
|
||||
class ContentLibrary(models.Model):
|
||||
"""
|
||||
A Content Library is a collection of content (XBlocks and/or static assets)
|
||||
|
||||
All actual content is stored in Blockstore, and any data that we'd want to
|
||||
transfer to another instance if this library were exported and then
|
||||
re-imported on another Open edX instance should be kept in Blockstore. This
|
||||
model in the LMS should only be used to track settings specific to this Open
|
||||
edX instance, like who has permission to edit this content library.
|
||||
"""
|
||||
objects = ContentLibraryManager()
|
||||
|
||||
id = models.AutoField(primary_key=True)
|
||||
# Every Library is uniquely and permanently identified by an 'org' and a
|
||||
# 'slug' that are set during creation/import. Both will appear in the
|
||||
# library's opaque key:
|
||||
# e.g. "lib:org:slug" is the opaque key for a library.
|
||||
org = models.ForeignKey(Organization, on_delete=models.PROTECT, null=False)
|
||||
slug = models.SlugField()
|
||||
bundle_uuid = models.UUIDField(unique=True, null=False)
|
||||
|
||||
# How is this library going to be used?
|
||||
allow_public_learning = models.BooleanField(
|
||||
default=False,
|
||||
help_text=("""
|
||||
Allow any user (even unregistered users) to view and interact with
|
||||
content in this library (in the LMS; not in Studio). If this is not
|
||||
enabled, then the content in this library is not directly accessible
|
||||
in the LMS, and learners will only ever see this content if it is
|
||||
explicitly added to a course. If in doubt, leave this unchecked.
|
||||
"""),
|
||||
)
|
||||
allow_public_read = models.BooleanField(
|
||||
default=False,
|
||||
help_text=("""
|
||||
Allow any user with Studio access to view this library's content in
|
||||
Studio, use it in their courses, and copy content out of this
|
||||
library. If in doubt, leave this unchecked.
|
||||
"""),
|
||||
)
|
||||
|
||||
authorized_users = models.ManyToManyField(User, through='ContentLibraryPermission')
|
||||
|
||||
class Meta:
|
||||
verbose_name_plural = "Content Libraries"
|
||||
unique_together = ("org", "slug")
|
||||
|
||||
@property
|
||||
def library_key(self):
|
||||
"""
|
||||
Get the LibraryLocatorV2 opaque key for this library
|
||||
"""
|
||||
return LibraryLocatorV2(org=self.org.short_name, slug=self.slug)
|
||||
|
||||
def __str__(self):
|
||||
return "ContentLibrary ({})".format(six.text_type(self.library_key))
|
||||
|
||||
|
||||
@six.python_2_unicode_compatible # pylint: disable=model-missing-unicode
|
||||
class ContentLibraryPermission(models.Model):
|
||||
"""
|
||||
Row recording permissions for a content library
|
||||
"""
|
||||
library = models.ForeignKey(ContentLibrary, on_delete=models.CASCADE)
|
||||
user = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||
# TODO: allow permissions to be assign to a group, not just a user
|
||||
ADMIN_LEVEL = 'admin'
|
||||
AUTHOR_LEVEL = 'author'
|
||||
READ_LEVEL = 'read'
|
||||
ACCESS_LEVEL_CHOICES = (
|
||||
(ADMIN_LEVEL, _("Administer users and author content")),
|
||||
(AUTHOR_LEVEL, _("Author content")),
|
||||
(READ_LEVEL, _("Read-only")),
|
||||
)
|
||||
access_level = models.CharField(max_length=30, choices=ACCESS_LEVEL_CHOICES)
|
||||
|
||||
def __str__(self):
|
||||
return "ContentLibraryPermission ({} for {})".format(self.access_level, self.user.username)
|
||||
75
openedx/core/djangoapps/content_libraries/serializers.py
Normal file
75
openedx/core/djangoapps/content_libraries/serializers.py
Normal file
@@ -0,0 +1,75 @@
|
||||
"""
|
||||
Serializers for the content libraries REST API
|
||||
"""
|
||||
# pylint: disable=abstract-method
|
||||
from __future__ import absolute_import, division, print_function, unicode_literals
|
||||
|
||||
from rest_framework import serializers
|
||||
|
||||
|
||||
class ContentLibraryMetadataSerializer(serializers.Serializer):
|
||||
"""
|
||||
Serializer for ContentLibraryMetadata
|
||||
"""
|
||||
# We rename the primary key field to "id" in the REST API since API clients
|
||||
# often implement magic functionality for fields with that name, and "key"
|
||||
# is a reserved prop name in React
|
||||
id = serializers.CharField(source="key", read_only=True)
|
||||
org = serializers.SlugField(source="key.org")
|
||||
slug = serializers.SlugField(source="key.slug")
|
||||
bundle_uuid = serializers.UUIDField(format='hex_verbose', read_only=True)
|
||||
collection_uuid = serializers.UUIDField(format='hex_verbose', write_only=True)
|
||||
title = serializers.CharField()
|
||||
description = serializers.CharField(allow_blank=True)
|
||||
version = serializers.IntegerField(read_only=True)
|
||||
has_unpublished_changes = serializers.BooleanField(read_only=True)
|
||||
has_unpublished_deletes = serializers.BooleanField(read_only=True)
|
||||
|
||||
|
||||
class ContentLibraryUpdateSerializer(serializers.Serializer):
|
||||
"""
|
||||
Serializer for updating an existing content library
|
||||
"""
|
||||
# These are the only fields that support changes:
|
||||
title = serializers.CharField()
|
||||
description = serializers.CharField()
|
||||
|
||||
|
||||
class LibraryXBlockMetadataSerializer(serializers.Serializer):
|
||||
"""
|
||||
Serializer for LibraryXBlockMetadata
|
||||
"""
|
||||
id = serializers.CharField(source="usage_key", read_only=True)
|
||||
def_key = serializers.CharField(read_only=True)
|
||||
block_type = serializers.CharField(source="def_key.block_type")
|
||||
display_name = serializers.CharField(read_only=True)
|
||||
has_unpublished_changes = serializers.BooleanField(read_only=True)
|
||||
# When creating a new XBlock in a library, the slug becomes the ID part of
|
||||
# the definition key and usage key:
|
||||
slug = serializers.CharField(write_only=True)
|
||||
|
||||
|
||||
class LibraryXBlockTypeSerializer(serializers.Serializer):
|
||||
"""
|
||||
Serializer for LibraryXBlockType
|
||||
"""
|
||||
block_type = serializers.CharField()
|
||||
display_name = serializers.CharField()
|
||||
|
||||
|
||||
class LibraryXBlockCreationSerializer(serializers.Serializer):
|
||||
"""
|
||||
Serializer for adding a new XBlock to a content library
|
||||
"""
|
||||
# Parent block: optional usage key of an existing block to add this child
|
||||
# block to.
|
||||
parent_block = serializers.CharField(required=False)
|
||||
block_type = serializers.CharField()
|
||||
definition_id = serializers.SlugField()
|
||||
|
||||
|
||||
class LibraryXBlockOlxSerializer(serializers.Serializer):
|
||||
"""
|
||||
Serializer for representing an XBlock's OLX
|
||||
"""
|
||||
olx = serializers.CharField()
|
||||
@@ -0,0 +1,341 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Tests for Blockstore-based Content Libraries
|
||||
"""
|
||||
from __future__ import absolute_import, division, print_function, unicode_literals
|
||||
import unittest
|
||||
from uuid import UUID
|
||||
|
||||
from django.conf import settings
|
||||
from organizations.models import Organization
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
from student.tests.factories import UserFactory
|
||||
from openedx.core.lib import blockstore_api
|
||||
|
||||
# Define the URLs here - don't use reverse() because we want to detect
|
||||
# backwards-incompatible changes like changed URLs.
|
||||
URL_PREFIX = '/api/libraries/v2/'
|
||||
URL_LIB_CREATE = URL_PREFIX
|
||||
URL_LIB_DETAIL = URL_PREFIX + '{lib_key}/' # Get data about a library, update or delete library
|
||||
URL_LIB_BLOCK_TYPES = URL_LIB_DETAIL + 'block_types/' # Get the list of XBlock types that can be added to this library
|
||||
URL_LIB_COMMIT = URL_LIB_DETAIL + 'commit/' # Commit (POST) or revert (DELETE) all pending changes to this library
|
||||
URL_LIB_BLOCKS = URL_LIB_DETAIL + 'blocks/' # Get the list of XBlocks in this library, or add a new one
|
||||
URL_LIB_BLOCK = URL_PREFIX + 'blocks/{block_key}/' # Get data about a block, or delete it
|
||||
URL_LIB_BLOCK_OLX = URL_LIB_BLOCK + 'olx/' # Get or set the OLX of the specified XBlock
|
||||
|
||||
URL_BLOCK_RENDER_VIEW = '/api/xblock/v2/xblocks/{block_key}/view/{view_name}/'
|
||||
URL_BLOCK_GET_HANDLER_URL = '/api/xblock/v2/xblocks/{block_key}/handler_url/{handler_name}/'
|
||||
|
||||
|
||||
@unittest.skipUnless(settings.RUN_BLOCKSTORE_TESTS, "Requires a running Blockstore server")
|
||||
class ContentLibrariesTest(APITestCase):
|
||||
"""
|
||||
Test for Blockstore-based Content Libraries
|
||||
|
||||
These tests use the REST API, which in turn relies on the Python API.
|
||||
Some tests may use the python API directly if necessary to provide
|
||||
coverage of any code paths not accessible via the REST API.
|
||||
|
||||
In general, these tests should
|
||||
(1) Use public APIs only - don't directly create data using other methods,
|
||||
which results in a less realistic test and ties the test suite too
|
||||
closely to specific implementation details.
|
||||
(Exception: users can be provisioned using a user factory)
|
||||
(2) Assert that fields are present in responses, but don't assert that the
|
||||
entire response has some specific shape. That way, things like adding
|
||||
new fields to an API response, which are backwards compatible, won't
|
||||
break any tests, but backwards-incompatible API changes will.
|
||||
|
||||
WARNING: every test should have a unique library slug, because even though
|
||||
the django/mysql database gets reset for each test case, the lookup between
|
||||
library slug and bundle UUID does not because it's assumed to be immutable
|
||||
and cached forever.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super(ContentLibrariesTest, cls).setUpClass()
|
||||
cls.user = UserFactory.create(username="Bob", email="bob@example.com", password="edx")
|
||||
# Create a collection using Blockstore API directly only because there
|
||||
# is not yet any Studio REST API for doing so:
|
||||
cls.collection = blockstore_api.create_collection("Content Library Test Collection")
|
||||
# Create an organization
|
||||
cls.organization = Organization.objects.create(
|
||||
name="Content Libraries Tachyon Exploration & Survey Team",
|
||||
short_name="CL-TEST",
|
||||
)
|
||||
|
||||
def setUp(self):
|
||||
super(ContentLibrariesTest, self).setUp()
|
||||
self.client.login(username=self.user.username, password="edx")
|
||||
|
||||
# API helpers
|
||||
|
||||
def _api(self, method, url, data, expect_response):
|
||||
"""
|
||||
Call a REST API
|
||||
"""
|
||||
response = getattr(self.client, method)(url, data, format="json")
|
||||
self.assertEqual(
|
||||
response.status_code, expect_response,
|
||||
"Unexpected response code {}:\n{}".format(response.status_code, getattr(response, 'data', '(no data)')),
|
||||
)
|
||||
return response.data
|
||||
|
||||
def _create_library(self, slug, title, description="", expect_response=200):
|
||||
""" Create a library """
|
||||
return self._api('post', URL_LIB_CREATE, {
|
||||
"org": self.organization.short_name,
|
||||
"slug": slug,
|
||||
"title": title,
|
||||
"description": description,
|
||||
"collection_uuid": str(self.collection.uuid),
|
||||
}, expect_response)
|
||||
|
||||
def _get_library(self, lib_key, expect_response=200):
|
||||
""" Get a library """
|
||||
return self._api('get', URL_LIB_DETAIL.format(lib_key=lib_key), None, expect_response)
|
||||
|
||||
def _update_library(self, lib_key, **data):
|
||||
""" Update an existing library """
|
||||
return self._api('patch', URL_LIB_DETAIL.format(lib_key=lib_key), data=data, expect_response=200)
|
||||
|
||||
def _delete_library(self, lib_key, expect_response=200):
|
||||
""" Delete an existing library """
|
||||
return self._api('delete', URL_LIB_DETAIL.format(lib_key=lib_key), None, expect_response)
|
||||
|
||||
def _commit_library_changes(self, lib_key):
|
||||
""" Commit changes to an existing library """
|
||||
return self._api('post', URL_LIB_COMMIT.format(lib_key=lib_key), None, expect_response=200)
|
||||
|
||||
def _revert_library_changes(self, lib_key):
|
||||
""" Revert pending changes to an existing library """
|
||||
return self._api('delete', URL_LIB_COMMIT.format(lib_key=lib_key), None, expect_response=200)
|
||||
|
||||
def _get_library_blocks(self, lib_key):
|
||||
""" Get the list of XBlocks in the library """
|
||||
return self._api('get', URL_LIB_BLOCKS.format(lib_key=lib_key), None, expect_response=200)
|
||||
|
||||
def _add_block_to_library(self, lib_key, block_type, slug, parent_block=None, expect_response=200):
|
||||
""" Add a new XBlock to the library """
|
||||
data = {"block_type": block_type, "definition_id": slug}
|
||||
if parent_block:
|
||||
data["parent_block"] = parent_block
|
||||
return self._api('post', URL_LIB_BLOCKS.format(lib_key=lib_key), data, expect_response)
|
||||
|
||||
def _get_library_block(self, block_key, expect_response=200):
|
||||
""" Get a specific block in the library """
|
||||
return self._api('get', URL_LIB_BLOCK.format(block_key=block_key), None, expect_response)
|
||||
|
||||
def _delete_library_block(self, block_key, expect_response=200):
|
||||
""" Delete a specific block from the library """
|
||||
self._api('delete', URL_LIB_BLOCK.format(block_key=block_key), None, expect_response)
|
||||
|
||||
def _get_library_block_olx(self, block_key, expect_response=200):
|
||||
""" Get the OLX of a specific block in the library """
|
||||
result = self._api('get', URL_LIB_BLOCK_OLX.format(block_key=block_key), None, expect_response)
|
||||
if expect_response == 200:
|
||||
return result["olx"]
|
||||
return result
|
||||
|
||||
def _set_library_block_olx(self, block_key, new_olx, expect_response=200):
|
||||
""" Overwrite the OLX of a specific block in the library """
|
||||
return self._api('post', URL_LIB_BLOCK_OLX.format(block_key=block_key), {"olx": new_olx}, expect_response)
|
||||
|
||||
def _render_block_view(self, block_key, view_name, expect_response=200):
|
||||
"""
|
||||
Render an XBlock's view in the active application's runtime.
|
||||
Note that this endpoint has different behavior in Studio (draft mode)
|
||||
vs. the LMS (published version only).
|
||||
"""
|
||||
url = URL_BLOCK_RENDER_VIEW.format(block_key=block_key, view_name=view_name)
|
||||
return self._api('get', url, None, expect_response)
|
||||
|
||||
def _get_block_handler_url(self, block_key, handler_name):
|
||||
"""
|
||||
Get the URL to call a specific XBlock's handler.
|
||||
The URL itself encodes authentication information so can be called
|
||||
without session authentication or any other kind of authentication.
|
||||
"""
|
||||
url = URL_BLOCK_GET_HANDLER_URL.format(block_key=block_key, handler_name=handler_name)
|
||||
return self._api('get', url, None, expect_response=200)["handler_url"]
|
||||
|
||||
# General Content Library tests
|
||||
|
||||
def test_library_crud(self):
|
||||
"""
|
||||
Test Create, Read, Update, and Delete of a Content Library
|
||||
"""
|
||||
# Create:
|
||||
lib = self._create_library(slug="lib-crud", title="A Test Library", description="Just Testing")
|
||||
expected_data = {
|
||||
"id": "lib:CL-TEST:lib-crud",
|
||||
"org": "CL-TEST",
|
||||
"slug": "lib-crud",
|
||||
"title": "A Test Library",
|
||||
"description": "Just Testing",
|
||||
"version": 0,
|
||||
"has_unpublished_changes": False,
|
||||
"has_unpublished_deletes": False,
|
||||
}
|
||||
self.assertDictContainsSubset(expected_data, lib)
|
||||
# Check that bundle_uuid looks like a valid UUID
|
||||
UUID(lib["bundle_uuid"]) # will raise an exception if not valid
|
||||
|
||||
# Read:
|
||||
lib2 = self._get_library(lib["id"])
|
||||
self.assertDictContainsSubset(expected_data, lib2)
|
||||
|
||||
# Update:
|
||||
lib3 = self._update_library(lib["id"], title="New Title")
|
||||
expected_data["title"] = "New Title"
|
||||
self.assertDictContainsSubset(expected_data, lib3)
|
||||
|
||||
# Delete:
|
||||
self._delete_library(lib["id"])
|
||||
# And confirm it is deleted:
|
||||
self._get_library(lib["id"], expect_response=404)
|
||||
self._delete_library(lib["id"], expect_response=404)
|
||||
|
||||
def test_library_validation(self):
|
||||
"""
|
||||
You can't create a library with the same slug as an existing library,
|
||||
or an invalid slug.
|
||||
"""
|
||||
self._create_library(slug="some-slug", title="Existing Library")
|
||||
self._create_library(slug="some-slug", title="Duplicate Library", expect_response=400)
|
||||
|
||||
self._create_library(slug="Invalid Slug!", title="Library with Bad Slug", expect_response=400)
|
||||
|
||||
# General Content Library XBlock tests:
|
||||
|
||||
def test_library_blocks(self):
|
||||
"""
|
||||
Test the happy path of creating and working with XBlocks in a content
|
||||
library.
|
||||
"""
|
||||
lib = self._create_library(slug="testlib1", title="A Test Library", description="Testing XBlocks")
|
||||
lib_id = lib["id"]
|
||||
self.assertEqual(lib["has_unpublished_changes"], False)
|
||||
|
||||
# A library starts out empty:
|
||||
self.assertEqual(self._get_library_blocks(lib_id), [])
|
||||
|
||||
# Add a 'problem' XBlock to the library:
|
||||
block_data = self._add_block_to_library(lib_id, "problem", "problem1")
|
||||
self.assertDictContainsSubset({
|
||||
"id": "lb:CL-TEST:testlib1:problem:problem1",
|
||||
"display_name": "Blank Advanced Problem",
|
||||
"block_type": "problem",
|
||||
"has_unpublished_changes": True,
|
||||
}, block_data)
|
||||
block_id = block_data["id"]
|
||||
# Confirm that the result contains a definition key, but don't check its value,
|
||||
# which for the purposes of these tests is an implementation detail.
|
||||
self.assertIn("def_key", block_data)
|
||||
|
||||
# now the library should contain one block and have unpublished changes:
|
||||
self.assertEqual(self._get_library_blocks(lib_id), [block_data])
|
||||
self.assertEqual(self._get_library(lib_id)["has_unpublished_changes"], True)
|
||||
|
||||
# Publish the changes:
|
||||
self._commit_library_changes(lib_id)
|
||||
self.assertEqual(self._get_library(lib_id)["has_unpublished_changes"], False)
|
||||
# And now the block information should also show that block has no unpublished changes:
|
||||
block_data["has_unpublished_changes"] = False
|
||||
self.assertDictContainsSubset(block_data, self._get_library_block(block_id))
|
||||
self.assertEqual(self._get_library_blocks(lib_id), [block_data])
|
||||
|
||||
# Now update the block's OLX:
|
||||
orig_olx = self._get_library_block_olx(block_id)
|
||||
self.assertIn("<problem", orig_olx)
|
||||
new_olx = """
|
||||
<problem display_name="New Multi Choice Question" max_attempts="5">
|
||||
<multiplechoiceresponse>
|
||||
<p>This is a normal capa problem. It has "maximum attempts" set to **5**.</p>
|
||||
<label>Blockstore is designed to store.</label>
|
||||
<choicegroup type="MultipleChoice">
|
||||
<choice correct="false">XBlock metadata only</choice>
|
||||
<choice correct="true">XBlock data/metadata and associated static asset files</choice>
|
||||
<choice correct="false">Static asset files for XBlocks and courseware</choice>
|
||||
<choice correct="false">XModule metadata only</choice>
|
||||
</choicegroup>
|
||||
</multiplechoiceresponse>
|
||||
</problem>
|
||||
""".strip()
|
||||
self._set_library_block_olx(block_id, new_olx)
|
||||
# now reading it back, we should get that exact OLX (no change to whitespace etc.):
|
||||
self.assertEqual(self._get_library_block_olx(block_id), new_olx)
|
||||
# And the display name and "unpublished changes" status of the block should be updated:
|
||||
self.assertDictContainsSubset({
|
||||
"display_name": "New Multi Choice Question",
|
||||
"has_unpublished_changes": True,
|
||||
}, self._get_library_block(block_id))
|
||||
|
||||
# Now view the XBlock's student_view (including draft changes):
|
||||
fragment = self._render_block_view(block_id, "student_view")
|
||||
self.assertIn("resources", fragment)
|
||||
self.assertIn("Blockstore is designed to store.", fragment["content"])
|
||||
|
||||
# Also call a handler to make sure that's working:
|
||||
handler_url = self._get_block_handler_url(block_id, "xmodule_handler") + "problem_get"
|
||||
problem_get_response = self.client.get(handler_url)
|
||||
self.assertEqual(problem_get_response.status_code, 200)
|
||||
self.assertIn("You have used 0 of 5 attempts", problem_get_response.content)
|
||||
|
||||
# Now delete the block:
|
||||
self.assertEqual(self._get_library(lib_id)["has_unpublished_deletes"], False)
|
||||
self._delete_library_block(block_id)
|
||||
# Confirm it's deleted:
|
||||
self._render_block_view(block_id, "student_view", expect_response=404)
|
||||
self._get_library_block(block_id, expect_response=404)
|
||||
self.assertEqual(self._get_library(lib_id)["has_unpublished_deletes"], True)
|
||||
|
||||
# Now revert all the changes back until the last publish:
|
||||
self._revert_library_changes(lib_id)
|
||||
self.assertEqual(self._get_library(lib_id)["has_unpublished_deletes"], False)
|
||||
self.assertEqual(self._get_library_block_olx(block_id), orig_olx)
|
||||
|
||||
# fin
|
||||
|
||||
def test_library_blocks_with_hierarchy(self):
|
||||
"""
|
||||
Test library blocks with children
|
||||
"""
|
||||
lib = self._create_library(slug="hierarchy_test_lib", title="A Test Library")
|
||||
lib_id = lib["id"]
|
||||
|
||||
# Add a 'unit' XBlock to the library:
|
||||
unit_block = self._add_block_to_library(lib_id, "unit", "unit1")
|
||||
# Add an HTML child block:
|
||||
child1 = self._add_block_to_library(lib_id, "html", "html1", parent_block=unit_block["id"])
|
||||
self._set_library_block_olx(child1["id"], "<html>Hello world</html>")
|
||||
# Add a problem child block:
|
||||
child2 = self._add_block_to_library(lib_id, "problem", "problem1", parent_block=unit_block["id"])
|
||||
self._set_library_block_olx(child2["id"], """
|
||||
<problem><multiplechoiceresponse>
|
||||
<p>What is an even number?</p>
|
||||
<choicegroup type="MultipleChoice">
|
||||
<choice correct="false">3</choice>
|
||||
<choice correct="true">2</choice>
|
||||
</choicegroup>
|
||||
</multiplechoiceresponse></problem>
|
||||
""")
|
||||
|
||||
# Check the resulting OLX of the unit:
|
||||
self.assertEqual(self._get_library_block_olx(unit_block["id"]), (
|
||||
'<unit xblock-family="xblock.v1">\n'
|
||||
' <xblock-include definition="html/html1"/>\n'
|
||||
' <xblock-include definition="problem/problem1"/>\n'
|
||||
'</unit>\n'
|
||||
))
|
||||
|
||||
# The unit can see and render its children:
|
||||
fragment = self._render_block_view(unit_block["id"], "student_view")
|
||||
self.assertIn("Hello world", fragment["content"])
|
||||
self.assertIn("What is an even number?", fragment["content"])
|
||||
|
||||
# We cannot add a duplicate ID to the library, either at the top level or as a child:
|
||||
self._add_block_to_library(lib_id, "problem", "problem1", expect_response=400)
|
||||
self._add_block_to_library(lib_id, "problem", "problem1", parent_block=unit_block["id"], expect_response=400)
|
||||
38
openedx/core/djangoapps/content_libraries/urls.py
Normal file
38
openedx/core/djangoapps/content_libraries/urls.py
Normal file
@@ -0,0 +1,38 @@
|
||||
"""
|
||||
URL configuration for Studio's Content Libraries REST API
|
||||
"""
|
||||
from __future__ import absolute_import, division, print_function, unicode_literals
|
||||
|
||||
from django.conf.urls import include, url
|
||||
|
||||
from . import views
|
||||
|
||||
# These URLs are only used in Studio. The LMS already provides all the
|
||||
# API endpoints needed to serve XBlocks from content libraries using the
|
||||
# standard XBlock REST API (see openedx.core.django_apps.xblock.rest_api.urls)
|
||||
urlpatterns = [
|
||||
url(r'^api/libraries/v2/', include([
|
||||
# list of libraries / create a library:
|
||||
url(r'^$', views.LibraryRootView.as_view()),
|
||||
url(r'^(?P<lib_key_str>[^/]+)/', include([
|
||||
# get data about a library, update a library, or delete a library:
|
||||
url(r'^$', views.LibraryDetailsView.as_view()),
|
||||
# Get the list of XBlock types that can be added to this library
|
||||
url(r'^block_types/$', views.LibraryBlockTypesView.as_view()),
|
||||
# Get the list of XBlocks in this library, or add a new one:
|
||||
url(r'^blocks/$', views.LibraryBlocksView.as_view()),
|
||||
# Commit (POST) or revert (DELETE) all pending changes to this library:
|
||||
url(r'^commit/$', views.LibraryCommitView.as_view()),
|
||||
])),
|
||||
url(r'^blocks/(?P<usage_key_str>[^/]+)/', include([
|
||||
# Get metadata about a specific XBlock in this library, or delete the block:
|
||||
url(r'^$', views.LibraryBlockView.as_view()),
|
||||
# Get the OLX source code of the specified block:
|
||||
url(r'^olx/$', views.LibraryBlockOlxView.as_view()),
|
||||
# TODO: Publish the draft changes made to this block:
|
||||
# url(r'^commit/$', views.LibraryBlockCommitView.as_view()),
|
||||
# View todo: discard draft changes
|
||||
# Future: set a block's tags (tags are stored in a Tag bundle and linked in)
|
||||
])),
|
||||
])),
|
||||
]
|
||||
263
openedx/core/djangoapps/content_libraries/views.py
Normal file
263
openedx/core/djangoapps/content_libraries/views.py
Normal file
@@ -0,0 +1,263 @@
|
||||
"""
|
||||
REST API for Blockstore-based content libraries
|
||||
"""
|
||||
from __future__ import absolute_import, division, print_function, unicode_literals
|
||||
from functools import wraps
|
||||
import logging
|
||||
|
||||
from organizations.models import Organization
|
||||
from rest_framework.exceptions import NotFound, ValidationError
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.response import Response
|
||||
#from rest_framework import authentication, permissions
|
||||
|
||||
from openedx.core.lib.api.view_utils import view_auth_classes
|
||||
from . import api
|
||||
from .keys import LibraryLocatorV2, LibraryUsageLocatorV2
|
||||
from .serializers import (
|
||||
ContentLibraryMetadataSerializer,
|
||||
ContentLibraryUpdateSerializer,
|
||||
LibraryXBlockCreationSerializer,
|
||||
LibraryXBlockMetadataSerializer,
|
||||
LibraryXBlockTypeSerializer,
|
||||
LibraryXBlockOlxSerializer,
|
||||
)
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def convert_exceptions(fn):
|
||||
"""
|
||||
Catch any Content Library API exceptions that occur and convert them to
|
||||
DRF exceptions so DRF will return an appropriate HTTP response
|
||||
"""
|
||||
|
||||
@wraps(fn)
|
||||
def wrapped_fn(*args, **kwargs):
|
||||
try:
|
||||
return fn(*args, **kwargs)
|
||||
except api.ContentLibraryNotFound:
|
||||
log.exception("Content library not found")
|
||||
raise NotFound
|
||||
except api.ContentLibraryBlockNotFound:
|
||||
log.exception("XBlock not found in content library")
|
||||
raise NotFound
|
||||
except api.LibraryBlockAlreadyExists as exc:
|
||||
log.exception(exc.message)
|
||||
raise ValidationError(exc.message)
|
||||
return wrapped_fn
|
||||
|
||||
|
||||
@view_auth_classes()
|
||||
class LibraryRootView(APIView):
|
||||
"""
|
||||
Views to list, search for, and create content libraries.
|
||||
"""
|
||||
|
||||
def get(self, request):
|
||||
"""
|
||||
Return a list of all content libraries. This is a temporary view for
|
||||
development.
|
||||
"""
|
||||
result = api.list_libraries()
|
||||
return Response(ContentLibraryMetadataSerializer(result, many=True).data)
|
||||
|
||||
def post(self, request):
|
||||
"""
|
||||
Create a new content library.
|
||||
"""
|
||||
serializer = ContentLibraryMetadataSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
data = serializer.validated_data
|
||||
# Get the organization short_name out of the "key.org" pseudo-field that the serializer added:
|
||||
org_name = data["key"]["org"]
|
||||
# Move "slug" out of the "key.slug" pseudo-field that the serializer added:
|
||||
data["slug"] = data.pop("key")["slug"]
|
||||
try:
|
||||
org = Organization.objects.get(short_name=org_name)
|
||||
except Organization.DoesNotExist:
|
||||
raise ValidationError(detail={"org": "No such organization '{}' found.".format(org_name)})
|
||||
try:
|
||||
result = api.create_library(org=org, **data)
|
||||
except api.LibraryAlreadyExists:
|
||||
raise ValidationError(detail={"slug": "A library with that ID already exists."})
|
||||
# Grant the current user admin permissions on the library:
|
||||
api.set_library_user_permissions(result.key, request.user, api.AccessLevel.ADMIN_LEVEL)
|
||||
return Response(ContentLibraryMetadataSerializer(result).data)
|
||||
|
||||
|
||||
@view_auth_classes()
|
||||
class LibraryDetailsView(APIView):
|
||||
"""
|
||||
Views to work with a specific content library
|
||||
"""
|
||||
@convert_exceptions
|
||||
def get(self, request, lib_key_str):
|
||||
"""
|
||||
Get a specific content library
|
||||
"""
|
||||
key = LibraryLocatorV2.from_string(lib_key_str)
|
||||
result = api.get_library(key)
|
||||
return Response(ContentLibraryMetadataSerializer(result).data)
|
||||
|
||||
@convert_exceptions
|
||||
def patch(self, request, lib_key_str):
|
||||
"""
|
||||
Update a content library
|
||||
"""
|
||||
key = LibraryLocatorV2.from_string(lib_key_str)
|
||||
serializer = ContentLibraryUpdateSerializer(data=request.data, partial=True)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
api.update_library(key, **serializer.validated_data)
|
||||
result = api.get_library(key)
|
||||
return Response(ContentLibraryMetadataSerializer(result).data)
|
||||
|
||||
@convert_exceptions
|
||||
def delete(self, request, lib_key_str): # pylint: disable=unused-argument
|
||||
"""
|
||||
Delete a content library
|
||||
"""
|
||||
key = LibraryLocatorV2.from_string(lib_key_str)
|
||||
api.delete_library(key)
|
||||
return Response({})
|
||||
|
||||
|
||||
@view_auth_classes()
|
||||
class LibraryBlockTypesView(APIView):
|
||||
"""
|
||||
View to get the list of XBlock types that can be added to this library
|
||||
"""
|
||||
@convert_exceptions
|
||||
def get(self, request, lib_key_str):
|
||||
"""
|
||||
Get the list of XBlock types that can be added to this library
|
||||
"""
|
||||
key = LibraryLocatorV2.from_string(lib_key_str)
|
||||
result = api.get_allowed_block_types(key)
|
||||
return Response(LibraryXBlockTypeSerializer(result, many=True).data)
|
||||
|
||||
|
||||
@view_auth_classes()
|
||||
class LibraryCommitView(APIView):
|
||||
"""
|
||||
Commit/publish or revert all of the draft changes made to the library.
|
||||
"""
|
||||
@convert_exceptions
|
||||
def post(self, request, lib_key_str):
|
||||
"""
|
||||
Commit the draft changes made to the specified block and its
|
||||
descendants.
|
||||
"""
|
||||
key = LibraryLocatorV2.from_string(lib_key_str)
|
||||
api.publish_changes(key)
|
||||
return Response({})
|
||||
|
||||
@convert_exceptions
|
||||
def delete(self, request, lib_key_str): # pylint: disable=unused-argument
|
||||
"""
|
||||
Revent the draft changes made to the specified block and its
|
||||
descendants. Restore it to the last published version
|
||||
"""
|
||||
key = LibraryLocatorV2.from_string(lib_key_str)
|
||||
api.revert_changes(key)
|
||||
return Response({})
|
||||
|
||||
|
||||
@view_auth_classes()
|
||||
class LibraryBlocksView(APIView):
|
||||
"""
|
||||
Views to work with XBlocks in a specific content library.
|
||||
"""
|
||||
@convert_exceptions
|
||||
def get(self, request, lib_key_str):
|
||||
"""
|
||||
Get the list of all top-level blocks in this content library
|
||||
"""
|
||||
key = LibraryLocatorV2.from_string(lib_key_str)
|
||||
result = api.get_library_blocks(key)
|
||||
return Response(LibraryXBlockMetadataSerializer(result, many=True).data)
|
||||
|
||||
@convert_exceptions
|
||||
def post(self, request, lib_key_str):
|
||||
"""
|
||||
Add a new XBlock to this content library
|
||||
"""
|
||||
library_key = LibraryLocatorV2.from_string(lib_key_str)
|
||||
serializer = LibraryXBlockCreationSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
parent_block_usage_str = serializer.validated_data.pop("parent_block", None)
|
||||
if parent_block_usage_str:
|
||||
# Add this as a child of an existing block:
|
||||
parent_block_usage = LibraryUsageLocatorV2.from_string(parent_block_usage_str)
|
||||
if parent_block_usage.context_key != library_key:
|
||||
raise ValidationError(detail={"parent_block": "Usage ID doesn't match library ID in the URL."})
|
||||
result = api.create_library_block_child(parent_block_usage, **serializer.validated_data)
|
||||
else:
|
||||
# Create a new regular top-level block:
|
||||
result = api.create_library_block(library_key, **serializer.validated_data)
|
||||
return Response(LibraryXBlockMetadataSerializer(result).data)
|
||||
|
||||
|
||||
@view_auth_classes()
|
||||
class LibraryBlockView(APIView):
|
||||
"""
|
||||
Views to work with an existing XBlock in a content library.
|
||||
"""
|
||||
@convert_exceptions
|
||||
def get(self, request, usage_key_str):
|
||||
"""
|
||||
Get metadata about an existing XBlock in the content library
|
||||
"""
|
||||
key = LibraryUsageLocatorV2.from_string(usage_key_str)
|
||||
result = api.get_library_block(key)
|
||||
return Response(LibraryXBlockMetadataSerializer(result).data)
|
||||
|
||||
@convert_exceptions
|
||||
def delete(self, request, usage_key_str): # pylint: disable=unused-argument
|
||||
"""
|
||||
Delete a usage of a block from the library (and any children it has).
|
||||
|
||||
If this is the only usage of the block's definition within this library,
|
||||
both the definition and the usage will be deleted. If this is only one
|
||||
of several usages, the definition will be kept. Usages by linked bundles
|
||||
are ignored and will not prevent deletion of the definition.
|
||||
|
||||
If the usage points to a definition in a linked bundle, the usage will
|
||||
be deleted but the link and the linked bundle will be unaffected.
|
||||
"""
|
||||
key = LibraryUsageLocatorV2.from_string(usage_key_str)
|
||||
api.delete_library_block(key)
|
||||
return Response({})
|
||||
|
||||
|
||||
@view_auth_classes()
|
||||
class LibraryBlockOlxView(APIView):
|
||||
"""
|
||||
Views to work with an existing XBlock's OLX
|
||||
"""
|
||||
@convert_exceptions
|
||||
def get(self, request, usage_key_str):
|
||||
"""
|
||||
Get the block's OLX
|
||||
"""
|
||||
key = LibraryUsageLocatorV2.from_string(usage_key_str)
|
||||
xml_str = api.get_library_block_olx(key)
|
||||
return Response(LibraryXBlockOlxSerializer({"olx": xml_str}).data)
|
||||
|
||||
@convert_exceptions
|
||||
def post(self, request, usage_key_str):
|
||||
"""
|
||||
Replace the block's OLX.
|
||||
|
||||
This API is only meant for use by developers or API client applications.
|
||||
Very little validation is done.
|
||||
"""
|
||||
key = LibraryUsageLocatorV2.from_string(usage_key_str)
|
||||
serializer = LibraryXBlockOlxSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
new_olx_str = serializer.validated_data["olx"]
|
||||
try:
|
||||
api.set_library_block_olx(key, new_olx_str)
|
||||
except ValueError as err:
|
||||
raise ValidationError(detail=str(err))
|
||||
return Response(LibraryXBlockOlxSerializer({"olx": new_olx_str}).data)
|
||||
Reference in New Issue
Block a user