Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions cms/djangoapps/contentstore/views/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from .helpers import *
from .item import *
from .import_export import *
from .library import *
from .preview import *
from .public import *
from .export_git import *
Expand Down
5 changes: 5 additions & 0 deletions cms/djangoapps/contentstore/views/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,11 @@ def xblock_studio_url(xblock, parent_xblock=None):
url=reverse_course_url('course_handler', xblock.location.course_key),
usage_key=urllib.quote(unicode(xblock.location))
)
elif category == 'library':
course_key = xblock.location.course_key
if course_key.branch == "library":
course_key = course_key.for_branch(None) # library branch is implied, so make the URL prettier/consistent

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is it implied? You mention this is for the URL - what are the differences?

Also, how do you plan to handle multiple libraries in a single course? library1, library2, etc.? (If we do want it - I remember that the other approach was to create fake courses and handle everything outside of courses)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good questions. What this accomplishes is the following:

If I go to:

  • localhost:8001/course/course-v1:ProblemX+PR0B+2014 - This is the normal course view, showing blocks in the draft-branch branch
  • localhost:8001/library/course-v1:ProblemX+PR0B+2014 - This is the new library view. With the code above, this will look for library data in the library branch by default.
  • localhost:8001/library/course-v1:ProblemX+PR0B+2014+branch@library - This URL is the same thing, but with a CourseLocator string that explicitly specifies the branch. I found it a bit redundant and I thought it looks nicer and is more consistent without the +branch@library part there, so I included this code to make that optional.

If we wanted to allow multiple libraries per course (I don't recommend this though), then each library would have its own branch, and you'd access them like:
localhost:8001/library/course-v1:ProblemX+PR0B+2014+branch@problems-library
localhost:8001/library/course-v1:ProblemX+PR0B+2014+branch@content-library1
localhost:8001/library/course-v1:ProblemX+PR0B+2014+branch@content-library2

return reverse_course_url('library_handler', course_key)
else:
return reverse_usage_url('container_handler', xblock.location)

Expand Down
19 changes: 12 additions & 7 deletions cms/djangoapps/contentstore/views/item.py
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,7 @@ def xblock_view_handler(request, usage_key_string, view_name):

if 'application/json' in accept_header:
store = modulestore()
xblock = store.get_item(usage_key)
xblock = store.get_item(usage_key, remove_branch=False)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why removing the branch information here? What are the effects on the rest of the courseware content?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This change is to not remove branch information

This is related to your last question. Here's why I had to add remove_branch=False throughout this file:

A lot of the methods on the MixedModuleStore have that modulestore's strip_key decorator. As a result, if you make any request to the modulestore involving an xblock on a specific branch, it will return results with the branch information stripped out. To me, that's a strange thing to be doing by default at such a low level, but I assume this was done to make working with draft/published branches easier. Unfortunately it makes life harder for our library use case.

For example, it you retrieve the library's root XBlock using modulestore().get_item() and you request the XBLock

block-v1:ProblemX+PR0B+2014+branch@library+type@library+block@library

then it will find and return the XBlock, but that XBlock's location property will say:

block-v1:ProblemX+PR0B+2014+type@library+block@library

so the returned block ID is actually wrong and doesn't exist.

This was causing all sorts of errors with studio's RESTful XBlock editing API when trying to use the library branch. However, thankfully the strip_key decorator is designed so that the end user can override its behaviour whenever necessary by passing in remove_branch=x, which is what I needed to do.

I think this decorator on the modulestore level didn't affect the existing studio code because the default draft-branch was implied for all CMS changes, so it doesn't care if the branch info is there or not. As a result, I think this won't affect the normal courseware editing. However, I will definitely need to do some careful testing to be sure.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for the explanations. It could be worth including a note/question about this in the specifications, to get feedback on the approach - this will likely show up in the first code review, but it could be useful to raise the issue early.

container_views = ['container_preview', 'reorderable_container_child_preview']

# wrap the generated fragment in the xmodule_editor div so that the javascript
Expand Down Expand Up @@ -314,7 +314,7 @@ def _update_with_callback(xblock, user, old_metadata=None, old_content=None):
xblock.editor_saved(user, old_metadata, old_content)

# Update after the callback so any changes made in the callback will get persisted.
return modulestore().update_item(xblock, user.id)
return modulestore().update_item(xblock, user.id, remove_branch=False)


def _save_xblock(user, xblock, data=None, children_strings=None, metadata=None, nullout=None,
Expand Down Expand Up @@ -462,7 +462,7 @@ def _create_item(request):

store = modulestore()
with store.bulk_operations(usage_key.course_key):
parent = store.get_item(usage_key)
parent = store.get_item(usage_key, remove_branch=False)
dest_usage_key = usage_key.replace(category=category, name=uuid4().hex)

# get the metadata, display_name, and definition from the request
Expand Down Expand Up @@ -493,6 +493,7 @@ def _create_item(request):
definition_data=data,
metadata=metadata,
runtime=parent.runtime,
remove_branch=False,
)

# VS[compat] cdodge: This is a hack because static_tabs also have references from the course module, so
Expand All @@ -518,7 +519,7 @@ def _duplicate_item(parent_usage_key, duplicate_source_usage_key, user, display_
"""
store = modulestore()
with store.bulk_operations(duplicate_source_usage_key.course_key):
source_item = store.get_item(duplicate_source_usage_key)
source_item = store.get_item(duplicate_source_usage_key, remove_branch=False)
# Change the blockID to be unique.
dest_usage_key = source_item.location.replace(name=uuid4().hex)
category = dest_usage_key.block_type
Expand All @@ -541,6 +542,7 @@ def _duplicate_item(parent_usage_key, duplicate_source_usage_key, user, display_
definition_data=source_item.get_explicitly_set_fields_by_scope(Scope.content),
metadata=duplicate_metadata,
runtime=source_item.runtime,
remove_branch=False,
)

# Children are not automatically copied over (and not all xblocks have a 'children' attribute).
Expand All @@ -553,7 +555,7 @@ def _duplicate_item(parent_usage_key, duplicate_source_usage_key, user, display_
store.update_item(dest_module, user.id)

if 'detached' not in source_item.runtime.load_block_type(category)._class_tags:
parent = store.get_item(parent_usage_key)
parent = store.get_item(parent_usage_key, remove_branch=False)
# If source was already a child of the parent, add duplicate immediately afterward.
# Otherwise, add child to end.
if source_item.location in parent.children:
Expand Down Expand Up @@ -623,7 +625,7 @@ def _get_xblock(usage_key, user):
store = modulestore()
with store.bulk_operations(usage_key.course_key):
try:
return store.get_item(usage_key, depth=None)
return store.get_item(usage_key, depth=None, remove_branch=False)
except ItemNotFoundError:
if usage_key.category in CREATE_IF_NOT_FOUND:
# Create a new one for certain categories only. Used for course info handouts.
Expand Down Expand Up @@ -696,7 +698,10 @@ def safe_get_username(user_id):
has_changes = modulestore().has_changes(xblock) if (is_xblock_unit or course_outline) else None

if graders is None:
graders = CourseGradingModel.fetch(xblock.location.course_key).graders
if xblock.category != "library":
graders = CourseGradingModel.fetch(xblock.location.course_key).graders
else:
graders = []

# Compute the child info first so it can be included in aggregate information for the parent
should_visit_children = include_child_info and (course_outline and not is_xblock_unit or not course_outline)
Expand Down
148 changes: 148 additions & 0 deletions cms/djangoapps/contentstore/views/library.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
"""
Views related to content libraries.
A content library is an optional branch that contains a flat list of XBlocks
which can be re-used in the "normal" branches of the course or other courses.
"""
from __future__ import absolute_import

import json
import logging

from contentstore.views.item import create_xblock_info
from django.http import HttpResponse, HttpResponseBadRequest, Http404
from django.contrib.auth.decorators import login_required
from django.core.exceptions import PermissionDenied
from django.conf import settings
from django.utils.translation import ugettext as _
from django_future.csrf import ensure_csrf_cookie
from edxmako.shortcuts import render_to_response
from opaque_keys.edx.keys import CourseKey
from xmodule.library_module import LibraryDescriptor
from xmodule.modulestore.exceptions import ItemNotFoundError
from xmodule.modulestore import ModuleStoreEnum
from xmodule.modulestore.django import modulestore

from .access import has_course_access
from .component import get_component_templates
from util.json_request import JsonResponse

__all__ = ['library_handler']

log = logging.getLogger(__name__)

LIBRARIES_ENABLED = settings.FEATURES.get('ENABLE_CONTENT_LIBRARIES', False)


@login_required
@ensure_csrf_cookie
def library_handler(request, course_key_string=None):
"""
RESTful interface to most content library related functionality.
"""
if not LIBRARIES_ENABLED:
raise Http404 # Should never happen because we test the feature in urls.py also

response_format = 'html'
if request.REQUEST.get('format', 'html') == 'json' or 'application/json' in request.META.get('HTTP_ACCEPT', 'text/html'):
response_format = 'json'

if course_key_string:
course_key = CourseKey.from_string(course_key_string)
if not has_course_access(request.user, course_key):
raise PermissionDenied()

if course_key.deprecated:
# Only courses stored in Split Mongo will work, and split requires Locators, not deprecated keys
return HttpResponseBadRequest("This course's modulestore does not support content libraries.")

if not course_key.branch:
course_key = course_key.for_branch("library")

store = modulestore()

try:
library = store.get_course(course_key, remove_branch=False)
if library is None:
raise ItemNotFoundError # Inconsistency: mixed modulestore returns None, whereas split raises exception
except ItemNotFoundError:
if store.has_course(course_key.for_branch(None)):
# There is a course, but no library [yet]
if "create" in request.GET:
# Create the library branch & root XBlock:
store.create_branch(
org=course_key.org,
course=course_key.course,
run=course_key.run,
branch='library',
user_id=request.user.id,
fields={"display_name": "New Library"},
root_category='library',
root_block_id='library',
)
return JsonResponse({
"result": "success",
})
return HttpResponse(
"<html><body>"
"No library exists for {course_id}. Would you like to create one? "
"<a href=\"?create\">Yes</a>"
"</body></html>"
.format(course_id=course_key.for_branch(None))
)
else:
raise Http404

if not isinstance(library, LibraryDescriptor):
return HttpResponseBadRequest("Course key specified is not a library.")

if request.method == 'GET':
return library_blocks_view(request, library, response_format)
return HttpResponseBadRequest("Invalid request method.")

# List all courses with a library:
split_store = modulestore()._get_modulestore_by_type(ModuleStoreEnum.Type.split)
libraries = []
for i in split_store.find_matching_course_indexes("library"):
libraries.append({
"version": "{}".format(i["versions"]["library"]),
"course": i["course"],
"org": i["org"],
"run": i["run"],
})
return JsonResponse(libraries)


def library_blocks_view(request, library, response_format):
"""
The main view of a course's content library.
Shows all the XBlocks in the library, and allows adding/editing/deleting
them.
Can be called with response_format="json" to get a JSON-formatted list of
the XBlocks in the library along with library metadata.
"""
children = library.children
if response_format == "json":
# The JSON response for this request is short and sweet:
prev_version = library.runtime.course_entry.structure['previous_version']
return JsonResponse({
"display_name": library.display_name,
"library_id": unicode(library.location.course_key), # library.course_id raises UndefinedContext - fix?
"version": unicode(library.runtime.course_entry.course_key.version),
"previous_version": unicode(prev_version) if prev_version else None,
"blocks": [unicode(x) for x in children],
})

course = modulestore().get_course(library.location.course_key.for_branch(None))
xblock_info = create_xblock_info(library, include_ancestor_info=False, graders=[])

component_templates = get_component_templates(course)

return render_to_response('library.html', {
'context_course': course, # Needed only for display of menus at top of page.
'action': 'view',
'xblock': library,
'xblock_locator': library.location,
'unit': None,
'component_templates': json.dumps(component_templates),
'xblock_info': xblock_info,
})
102 changes: 102 additions & 0 deletions cms/templates/library.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
<%inherit file="base.html" />
<%!
import json

from contentstore.views.helpers import xblock_studio_url, xblock_type_display_name
from django.utils.translation import ugettext as _
%>
<%block name="title">${xblock.display_name_with_default} ${xblock_type_display_name(xblock)}</%block>
<%block name="bodyclass">is-signedin course container view-container</%block>

<%namespace name='static' file='static_content.html'/>

<%!
templates = ["basic-modal", "modal-button", "edit-xblock-modal",
"editor-mode-button", "upload-dialog", "image-modal",
"add-xblock-component", "add-xblock-component-button", "add-xblock-component-menu",
"add-xblock-component-menu-problem", "xblock-string-field-editor", "publish-xblock", "publish-history",
"unit-outline", "container-message"]
%>
<%block name="header_extras">
% for template_name in templates:
<script type="text/template" id="${template_name}-tpl">
<%static:include path="js/${template_name}.underscore" />
</script>
% endfor
<link rel="stylesheet" type="text/css" href="${static.url('js/vendor/timepicker/jquery.timepicker.css')}" />
</%block>

<%block name="jsextra">
<script type='text/javascript'>
require(["domReady!", "jquery", "js/models/xblock_info", "js/views/pages/container",
"js/collections/component_template", "xmodule", "coffee/src/main", "xblock/cms.runtime.v1"],
function(doc, $, XBlockInfo, ContainerPage, ComponentTemplates, xmoduleLoader) {
var templates = new ComponentTemplates(${component_templates | n}, {parse: true});
var mainXBlockInfo = new XBlockInfo(${json.dumps(xblock_info) | n}, {parse: true});

xmoduleLoader.done(function () {
var view = new ContainerPage({
el: $('#content'),
model: mainXBlockInfo,
action: "${action}",
templates: templates,
isUnitPage: false
});
view.render();
});
});
</script>

</%block>

<%block name="content">


<div class="wrapper-mast wrapper">
<header class="mast has-actions has-navigation has-subtitle">
<div class="page-header">
<div class="wrapper-xblock-field incontext-editor is-editable"
data-field="display_name" data-field-display-name="${_("Display Name")}">
<h1 class="page-header-title xblock-field-value incontext-editor-value"><span class="title-value">${xblock.display_name_with_default | h}</span></h1>
</div>
</div>

<nav class="nav-actions">
<h3 class="sr">${_("Page Actions")}</h3>
<ul>
<li class="action-item action-edit nav-item">
<a href="#" class="button button-edit action-button edit-button">
<i class="icon-pencil"></i>
<span class="action-button-text">${_("Edit")}</span>
</a>
</li>
</ul>
</nav>
</header>
</div>

<div class="wrapper-content wrapper">
<div class="inner-wrapper">
<section class="content-area">

<article class="content-primary">
<div class="container-message wrapper-message"></div>
<section class="wrapper-xblock level-page is-hidden studio-xblock-wrapper" data-locator="${xblock_locator | h}" data-course-key="${xblock_locator.course_key | h}">
</section>
<div class="ui-loading">
<p><span class="spin"><i class="icon-refresh"></i></span> <span class="copy">${_("Loading...")}</span></p>
</div>
</article>
<aside class="content-supplementary" role="complimentary">
<div class="bit">
<h3 class="title-3">${_("Adding content components")}</h3>
<p>${_("You can add compnents to the library. Help text here.")}</p>
</div>
<div class="bit external-help">
<a href="${get_online_help_info('library')['doc_url']}" target="_blank" class="button external-help-button">${_("Learn more about content libraries")}</a>
</div>
</aside>
</section>
</div>
</div>
</%block>
5 changes: 5 additions & 0 deletions cms/templates/widgets/header.html
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,11 @@ <h3 class="title"><span class="label"><span class="label-prefix sr">${_("Course"
<li class="nav-item nav-course-courseware-textbooks">
<a href="${textbooks_url}">${_("Textbooks")}</a>
</li>
% if settings.FEATURES.get('ENABLE_CONTENT_LIBRARIES') and not course_key.deprecated:
<li class="nav-item nav-course-courseware-library">
<a href="${reverse('contentstore.views.library_handler', kwargs={'course_key_string': unicode(course_key)})}">${_("Content Library")}</a>
</li>
% endif
</ul>
</div>
</div>
Expand Down
5 changes: 5 additions & 0 deletions cms/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,11 @@
url(r'^i18n.js$', 'django.views.i18n.javascript_catalog', js_info_dict),
)

if settings.FEATURES.get('ENABLE_CONTENT_LIBRARIES'):
urlpatterns += (
url(r'^library/{}?$'.format(settings.COURSE_KEY_PATTERN),
'contentstore.views.library_handler', name='library_handler'),
)

if settings.FEATURES.get('ENABLE_EXPORT_GIT'):
urlpatterns += (url(r'^export_git/{}$'.format(settings.COURSE_KEY_PATTERN),
Expand Down
2 changes: 2 additions & 0 deletions common/lib/xmodule/setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
"discuss = xmodule.backcompat_module:TranslateCustomTagDescriptor",
"html = xmodule.html_module:HtmlDescriptor",
"image = xmodule.backcompat_module:TranslateCustomTagDescriptor",
"library = xmodule.library_module:LibraryDescriptor",
"library_content = xmodule.library_content_module:LibraryContentDescriptor",
"error = xmodule.error_module:ErrorDescriptor",
"peergrading = xmodule.peer_grading_module:PeerGradingDescriptor",
"poll_question = xmodule.poll_module:PollDescriptor",
Expand Down
Loading