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:
Braden MacDonald
2019-08-30 09:50:21 -07:00
parent 7676858282
commit d3f6ed09d8
65 changed files with 5845 additions and 24 deletions

View 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", ]

View 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()

View 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: {
},
},
}

View 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)

View 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

View 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)

View File

@@ -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')]),
),
]

View 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)

View 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()

View File

@@ -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)

View 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)
])),
])),
]

View 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)