Commit 5e972b2a by Usman Khalid

Merge pull request #11022 from edx/bookmarking

Bookmarking
parents ca2b8c27 a93ef10f
......@@ -803,6 +803,9 @@ INSTALLED_APPS = (
# edX Proctoring
'edx_proctoring',
# Bookmarks
'openedx.core.djangoapps.bookmarks',
# programs support
'openedx.core.djangoapps.programs',
......
......@@ -11,13 +11,13 @@ from .views import (
EnrollmentCourseDetailView
)
USERNAME_PATTERN = '(?P<username>[\w.@+-]+)'
urlpatterns = patterns(
'enrollment.views',
url(
r'^enrollment/{username},{course_key}$'.format(username=USERNAME_PATTERN,
course_key=settings.COURSE_ID_PATTERN),
r'^enrollment/{username},{course_key}$'.format(
username=settings.USERNAME_PATTERN, course_key=settings.COURSE_ID_PATTERN
),
EnrollmentView.as_view(),
name='courseenrollment'
),
......
$sequence--border-color: #C8C8C8;
$link-color: rgb(26, 161, 222);
// repeated extends - needed since LMS styling was referenced
.block-link {
border-left: 1px solid lighten($sequence--border-color, 10%);
......@@ -36,7 +36,7 @@ $sequence--border-color: #C8C8C8;
// TODO (cpennington): This doesn't work anymore. XModules aren't able to
// import from external sources.
@extend .topbar;
margin: -4px 0 ($baseline*1.5);
margin: -4px 0 $baseline;
position: relative;
border-bottom: none;
z-index: 0;
......@@ -119,6 +119,10 @@ $sequence--border-color: #C8C8C8;
-webkit-font-smoothing: antialiased; // Clear up the lines on the icons
}
i.fa-bookmark {
color: $link-color;
}
&.inactive {
.icon {
......@@ -142,6 +146,10 @@ $sequence--border-color: #C8C8C8;
.icon {
color: rgb(10, 10, 10);
}
i.fa-bookmark {
color: $link-color;
}
}
}
......@@ -295,3 +303,4 @@ nav.sequence-bottom {
outline: none;
}
}
......@@ -18,6 +18,8 @@ class @Sequence
bind: ->
@$('#sequence-list a').click @goto
@el.on 'bookmark:add', @addBookmarkIconToActiveNavItem
@el.on 'bookmark:remove', @removeBookmarkIconFromActiveNavItem
initProgress: ->
@progressTable = {} # "#problem_#{id}" -> progress
......@@ -102,8 +104,9 @@ class @Sequence
@mark_active new_position
current_tab = @contents.eq(new_position - 1)
@content_container.html(current_tab.text()).attr("aria-labelledby", current_tab.attr("aria-labelledby"))
bookmarked = if @el.find('.active .bookmark-icon').hasClass('bookmarked') then true else false
@content_container.html(current_tab.text()).attr("aria-labelledby", current_tab.attr("aria-labelledby")).data('bookmarked', bookmarked)
XBlock.initializeBlocks(@content_container, @requestToken)
window.update_schematics() # For embedded circuit simulator exercises in 6.002x
......@@ -116,6 +119,8 @@ class @Sequence
sequence_links = @content_container.find('a.seqnav')
sequence_links.click @goto
@el.find('.path').html(@el.find('.nav-item.active').data('path'))
@sr_container.focus();
# @$("a.active").blur()
......@@ -180,3 +185,13 @@ class @Sequence
element.removeClass("inactive")
.removeClass("visited")
.addClass("active")
addBookmarkIconToActiveNavItem: (event) =>
event.preventDefault()
@el.find('.nav-item.active .bookmark-icon').removeClass('is-hidden').addClass('bookmarked')
@el.find('.nav-item.active .bookmark-icon-sr').text(gettext('Bookmarked'))
removeBookmarkIconFromActiveNavItem: (event) =>
event.preventDefault()
@el.find('.nav-item.active .bookmark-icon').removeClass('bookmarked').addClass('is-hidden')
@el.find('.nav-item.active .bookmark-icon-sr').text('')
......@@ -316,6 +316,7 @@ class LibraryContentModule(LibraryContentFields, XModule, StudioEditableModule):
fragment.add_content(self.system.render_template('vert_module.html', {
'items': contents,
'xblock_context': context,
'show_bookmark_button': False,
}))
return fragment
......
......@@ -6,7 +6,7 @@ from .exceptions import (ItemNotFoundError, NoPathToItem)
LOGGER = getLogger(__name__)
def path_to_location(modulestore, usage_key):
def path_to_location(modulestore, usage_key, full_path=False):
'''
Try to find a course_id/chapter/section[/position] path to location in
modulestore. The courseware insists that the first level in the course is
......@@ -15,6 +15,7 @@ def path_to_location(modulestore, usage_key):
Args:
modulestore: which store holds the relevant objects
usage_key: :class:`UsageKey` the id of the location to which to generate the path
full_path: :class:`Bool` if True, return the full path to location. Default is False.
Raises
ItemNotFoundError if the location doesn't exist.
......@@ -81,6 +82,9 @@ def path_to_location(modulestore, usage_key):
if path is None:
raise NoPathToItem(usage_key)
if full_path:
return path
n = len(path)
course_id = path[0].course_key
# pull out the location names
......
......@@ -18,10 +18,12 @@ from openedx.core.lib.tempdir import mkdtemp_clean
from xmodule.contentstore.django import _CONTENTSTORE
from xmodule.modulestore import ModuleStoreEnum
from xmodule.modulestore.django import modulestore, clear_existing_modulestores
from xmodule.modulestore.django import modulestore, clear_existing_modulestores, SignalHandler
from xmodule.modulestore.tests.mongo_connection import MONGO_PORT_NUM, MONGO_HOST
from xmodule.modulestore.tests.factories import XMODULE_FACTORY_LOCK
from openedx.core.djangoapps.bookmarks.signals import trigger_update_xblocks_cache_task
class StoreConstructors(object):
"""Enumeration of store constructor types."""
......@@ -405,6 +407,8 @@ class ModuleStoreTestCase(TestCase):
super(ModuleStoreTestCase, self).setUp()
SignalHandler.course_published.disconnect(trigger_update_xblocks_cache_task)
self.store = modulestore()
uname = 'testuser'
......
......@@ -917,6 +917,14 @@ class XMLModuleStore(ModuleStoreReadBase):
log.warning("get_all_asset_metadata request of XML modulestore - not implemented.")
return []
def fill_in_run(self, course_key):
"""
A no-op.
Added to simplify tests which use the XML-store directly.
"""
return course_key
class LibraryXMLModuleStore(XMLModuleStore):
"""
......
/* JavaScript for Vertical Student View. */
window.VerticalStudentView = function (runtime, element) {
'use strict';
RequireJS.require(['js/bookmarks/views/bookmark_button'], function (BookmarkButton) {
var $element = $(element);
var $bookmarkButtonElement = $element.find('.bookmark-button');
return new BookmarkButton({
el: $bookmarkButtonElement,
bookmarkId: $bookmarkButtonElement.data('bookmarkId'),
usageId: $element.data('usageId'),
bookmarked: $element.parent('#seq_content').data('bookmarked'),
apiUrl: $(".courseware-bookmarks-button").data('bookmarksApiUrl')
});
});
};
......@@ -119,9 +119,12 @@ class ProctoringFields(object):
@XBlock.wants('proctoring')
@XBlock.wants('credit')
@XBlock.needs("user")
@XBlock.needs("bookmarks")
class SequenceModule(SequenceFields, ProctoringFields, XModule):
''' Layout module which lays out content in a temporal sequence
'''
"""
Layout module which lays out content in a temporal sequence
"""
js = {
'coffee': [resource_string(__name__, 'js/src/sequence/display.coffee')],
'js': [resource_string(__name__, 'js/src/sequence/display/jquery.sequence.js')],
......@@ -182,7 +185,12 @@ class SequenceModule(SequenceFields, ProctoringFields, XModule):
contents = []
fragment = Fragment()
context = context or {}
bookmarks_service = self.runtime.service(self, "bookmarks")
context["username"] = self.runtime.service(self, "user").get_current_user().opt_attrs['edx-platform.username']
display_names = [self.get_parent().display_name or '', self.display_name or '']
# Is this sequential part of a timed or proctored exam?
if self.is_time_limited:
view_html = self._time_limited_student_view(context)
......@@ -194,6 +202,9 @@ class SequenceModule(SequenceFields, ProctoringFields, XModule):
return fragment
for child in self.get_display_items():
is_bookmarked = bookmarks_service.is_bookmarked(usage_key=child.scope_ids.usage_id)
context["bookmarked"] = is_bookmarked
progress = child.get_progress()
rendered_child = child.render(STUDENT_VIEW, context)
fragment.add_frag_resources(rendered_child)
......@@ -209,6 +220,8 @@ class SequenceModule(SequenceFields, ProctoringFields, XModule):
'progress_detail': Progress.to_js_detail_str(progress),
'type': child.get_icon_class(),
'id': child.scope_ids.usage_id.to_deprecated_string(),
'bookmarked': is_bookmarked,
'path': " > ".join(display_names + [child.display_name or '']),
}
if childinfo['title'] == '':
childinfo['title'] = child.display_name_with_default
......
......@@ -37,18 +37,31 @@ class BaseVerticalBlockTest(XModuleXmlImportTest):
self.vertical = course_seq.get_children()[0]
self.vertical.xmodule_runtime = self.module_system
self.username = "bilbo"
self.default_context = {"bookmarked": False, "username": self.username}
class VerticalBlockTestCase(BaseVerticalBlockTest):
"""
Tests for the VerticalBlock.
"""
def assert_bookmark_info_in(self, content):
"""
Assert content has all the bookmark info.
"""
self.assertIn('bookmark_id', content)
self.assertIn('{},{}'.format(self.username, unicode(self.vertical.location)), content)
self.assertIn('bookmarked', content)
self.assertIn('show_bookmark_button', content)
def test_render_student_view(self):
"""
Test the rendering of the student view.
"""
html = self.module_system.render(self.vertical, STUDENT_VIEW, {}).content
html = self.module_system.render(self.vertical, STUDENT_VIEW, self.default_context).content
self.assertIn(self.test_html_1, html)
self.assertIn(self.test_html_2, html)
self.assert_bookmark_info_in(html)
def test_render_studio_view(self):
"""
......
......@@ -54,7 +54,14 @@ class VerticalBlock(SequenceFields, XModuleFields, StudioEditableBlock, XmlParse
fragment.add_content(self.system.render_template('vert_module.html', {
'items': contents,
'xblock_context': context,
'show_bookmark_button': True,
'bookmarked': child_context['bookmarked'],
'bookmark_id': "{},{}".format(child_context['username'], unicode(self.location))
}))
fragment.add_javascript_url(self.runtime.local_resource_url(self, 'public/js/vertical_student_view.js'))
fragment.initialize_js('VerticalStudentView')
return fragment
def author_view(self, context):
......
<nav class="pagination pagination-full bottom" aria-label="Teams Pagination">
<nav class="pagination pagination-full bottom" aria-label="Pagination">
<div class="nav-item previous"><button class="nav-link previous-page-link"><i class="icon fa fa-angle-left" aria-hidden="true"></i> <span class="nav-label"><%= gettext("Previous") %></span></button></div>
<div class="nav-item page">
<div class="pagination-form">
......
......@@ -15,6 +15,7 @@ class PaginatedUIMixin(object):
PREVIOUS_PAGE_BUTTON_CSS = 'button.previous-page-link'
PAGINATION_HEADER_TEXT_CSS = 'div.search-tools'
CURRENT_PAGE_NUMBER_CSS = 'span.current-page'
TOTAL_PAGES_CSS = 'span.total-pages'
def get_pagination_header_text(self):
"""Return the text showing which items the user is currently viewing."""
......@@ -31,6 +32,11 @@ class PaginatedUIMixin(object):
"""Return the the current page number."""
return int(self.q(css=self.CURRENT_PAGE_NUMBER_CSS).text[0])
@property
def get_total_pages(self):
"""Returns the total page value"""
return int(self.q(css=self.TOTAL_PAGES_CSS).text[0])
def go_to_page(self, page_number):
"""Go to the given page_number in the paginated list results."""
self.q(css=self.PAGE_NUMBER_INPUT_CSS).results[0].send_keys(unicode(page_number), Keys.ENTER)
......
"""
Courseware Boomarks
"""
from bok_choy.promise import EmptyPromise
from .course_page import CoursePage
from ..common.paging import PaginatedUIMixin
class BookmarksPage(CoursePage, PaginatedUIMixin):
"""
Courseware Bookmarks Page.
"""
url = None
url_path = "courseware/"
BOOKMARKS_BUTTON_SELECTOR = '.bookmarks-list-button'
BOOKMARKED_ITEMS_SELECTOR = '.bookmarks-results-list .bookmarks-results-list-item'
BOOKMARKED_BREADCRUMBS = BOOKMARKED_ITEMS_SELECTOR + ' .list-item-breadcrumbtrail'
def is_browser_on_page(self):
""" Verify if we are on correct page """
return self.q(css=self.BOOKMARKS_BUTTON_SELECTOR).visible
def bookmarks_button_visible(self):
""" Check if bookmarks button is visible """
return self.q(css=self.BOOKMARKS_BUTTON_SELECTOR).visible
def click_bookmarks_button(self, wait_for_results=True):
""" Click on Bookmarks button """
self.q(css=self.BOOKMARKS_BUTTON_SELECTOR).first.click()
if wait_for_results:
EmptyPromise(self.results_present, "Bookmarks results present").fulfill()
def results_present(self):
""" Check if bookmarks results are present """
return self.q(css='#my-bookmarks').present
def results_header_text(self):
""" Returns the bookmarks results header text """
return self.q(css='.bookmarks-results-header').text[0]
def empty_header_text(self):
""" Returns the bookmarks empty header text """
return self.q(css='.bookmarks-empty-header').text[0]
def empty_list_text(self):
""" Returns the bookmarks empty list text """
return self.q(css='.bookmarks-empty-detail-title').text[0]
def count(self):
""" Returns the total number of bookmarks in the list """
return len(self.q(css=self.BOOKMARKED_ITEMS_SELECTOR).results)
def breadcrumbs(self):
""" Return list of breadcrumbs for all bookmarks """
breadcrumbs = self.q(css=self.BOOKMARKED_BREADCRUMBS).text
return [breadcrumb.replace('\n', '').split('-') for breadcrumb in breadcrumbs]
def click_bookmarked_block(self, index):
"""
Click on bookmarked block at index `index`
Arguments:
index (int): bookmark index in the list
"""
self.q(css=self.BOOKMARKED_ITEMS_SELECTOR).nth(index).click()
......@@ -193,13 +193,13 @@ class CourseNavPage(PageObject):
)
# Regular expression to remove HTML span tags from a string
REMOVE_SPAN_TAG_RE = re.compile(r'<span.+/span>')
REMOVE_SPAN_TAG_RE = re.compile(r'</span>(.+)<span')
def _clean_seq_titles(self, element):
"""
Clean HTML of sequence titles, stripping out span tags and returning the first line.
"""
return self.REMOVE_SPAN_TAG_RE.sub('', element.get_attribute('innerHTML')).strip().split('\n')[0]
return self.REMOVE_SPAN_TAG_RE.search(element.get_attribute('innerHTML')).groups()[0].strip()
def go_to_sequential_position(self, sequential_position):
"""
......
......@@ -3,6 +3,7 @@ Courseware page.
"""
from .course_page import CoursePage
from bok_choy.promise import EmptyPromise
from selenium.webdriver.common.action_chains import ActionChains
......@@ -171,6 +172,38 @@ class CoursewarePage(CoursePage):
"""
return self.q(css=".proctored_exam_status .exam-timer").is_present()
def active_usage_id(self):
""" Returns the usage id of active sequence item """
get_active = lambda el: 'active' in el.get_attribute('class')
attribute_value = lambda el: el.get_attribute('data-id')
return self.q(css='#sequence-list a').filter(get_active).map(attribute_value).results[0]
@property
def breadcrumb(self):
""" Return the course tree breadcrumb shown above the sequential bar """
return [part.strip() for part in self.q(css='.path').text[0].split('>')]
def bookmark_button_visible(self):
""" Check if bookmark button is visible """
EmptyPromise(lambda: self.q(css='.bookmark-button').visible, "Bookmark button visible").fulfill()
return True
@property
def bookmark_button_state(self):
""" Return `bookmarked` if button is in bookmarked state else '' """
return 'bookmarked' if self.q(css='.bookmark-button.bookmarked').present else ''
@property
def bookmark_icon_visible(self):
""" Check if bookmark icon is visible on active sequence nav item """
return self.q(css='.active .bookmark-icon').visible
def click_bookmark_unit_button(self):
""" Bookmark a unit by clicking on Bookmark button """
previous_state = self.bookmark_button_state
self.q(css='.bookmark-button').first.click()
EmptyPromise(lambda: self.bookmark_button_state != previous_state, "Bookmark button toggled").fulfill()
class CoursewareSequentialTabPage(CoursePage):
"""
......
......@@ -12,11 +12,12 @@ class CoursewareSearchPage(CoursePage):
url_path = "courseware/"
search_bar_selector = '#courseware-search-bar'
search_results_selector = '.courseware-results'
@property
def search_results(self):
""" search results list showing """
return self.q(css='#courseware-search-results')
return self.q(css=self.search_results_selector)
def is_browser_on_page(self):
""" did we find the search bar in the UI """
......@@ -30,6 +31,7 @@ class CoursewareSearchPage(CoursePage):
""" execute the search """
self.q(css=self.search_bar_selector + ' [type="submit"]').click()
self.wait_for_ajax()
self.wait_for_element_visibility(self.search_results_selector, 'Search results are visible')
def search_for_term(self, text):
"""
......
......@@ -564,7 +564,7 @@ class EdxNoteHighlight(NoteChild):
"""
Clicks cancel button.
"""
self.q(css=self._bounded_selector(".annotator-cancel")).first.click()
self.q(css=self._bounded_selector(".annotator-close")).first.click()
self.wait_for_notes_invisibility("Note is canceled.")
return self
......@@ -605,8 +605,7 @@ class EdxNoteHighlight(NoteChild):
text = element.text[0].strip()
else:
text = None
self.q(css=("body")).first.click()
self.wait_for_notes_invisibility()
self.cancel()
return text
@text.setter
......@@ -629,8 +628,7 @@ class EdxNoteHighlight(NoteChild):
if tags:
for tag in tags:
tag_text.append(tag.text)
self.q(css="body").first.click()
self.wait_for_notes_invisibility()
self.cancel()
return tag_text
@tags.setter
......
......@@ -344,6 +344,11 @@ def get_element_padding(page, selector):
return page.browser.execute_script(js_script)
def is_404_page(browser):
""" Check if page is 404 """
return 'Page not found (404)' in browser.find_element_by_tag_name('h1').text
class EventsTestMixin(TestCase):
"""
Helpers and setup for running tests that evaluate events emitted
......
......@@ -29,6 +29,7 @@ class CoursewareTest(UniqueCourseTest):
super(CoursewareTest, self).setUp()
self.courseware_page = CoursewarePage(self.browser, self.course_id)
self.course_nav = CourseNavPage(self.browser)
self.course_outline = CourseOutlinePage(
self.browser,
......@@ -38,12 +39,12 @@ class CoursewareTest(UniqueCourseTest):
)
# Install a course with sections/problems, tabs, updates, and handouts
course_fix = CourseFixture(
self.course_fix = CourseFixture(
self.course_info['org'], self.course_info['number'],
self.course_info['run'], self.course_info['display_name']
)
course_fix.add_children(
self.course_fix.add_children(
XBlockFixtureDesc('chapter', 'Test Section 1').add_children(
XBlockFixtureDesc('sequential', 'Test Subsection 1').add_children(
XBlockFixtureDesc('problem', 'Test Problem 1')
......@@ -67,6 +68,10 @@ class CoursewareTest(UniqueCourseTest):
self.problem_page = ProblemPage(self.browser)
self.assertEqual(self.problem_page.problem_name, 'TEST PROBLEM 1')
def _create_breadcrumb(self, index):
""" Create breadcrumb """
return ['Test Section {}'.format(index), 'Test Subsection {}'.format(index), 'Test Problem {}'.format(index)]
def _auto_auth(self, username, email, staff):
"""
Logout and login with given credentials.
......@@ -101,6 +106,23 @@ class CoursewareTest(UniqueCourseTest):
# Problem name should be "TEST PROBLEM 2".
self.assertEqual(self.problem_page.problem_name, 'TEST PROBLEM 2')
def test_course_tree_breadcrumb(self):
"""
Scenario: Correct course tree breadcrumb is shown.
Given that I am a registered user
And I visit my courseware page
Then I should see correct course tree breadcrumb
"""
self.courseware_page.visit()
xblocks = self.course_fix.get_nested_xblocks(category="problem")
for index in range(1, len(xblocks) + 1):
self.course_nav.go_to_section('Test Section {}'.format(index), 'Test Subsection {}'.format(index))
courseware_page_breadcrumb = self.courseware_page.breadcrumb
expected_breadcrumb = self._create_breadcrumb(index) # pylint: disable=no-member
self.assertEqual(courseware_page_breadcrumb, expected_breadcrumb)
class ProctoredExamTest(UniqueCourseTest):
"""
......
......@@ -48,6 +48,7 @@ from lms.djangoapps.lms_xblock.models import XBlockAsidesConfig
from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import UsageKey, CourseKey
from opaque_keys.edx.locations import SlashSeparatedCourseKey
from openedx.core.djangoapps.bookmarks.services import BookmarksService
from openedx.core.lib.xblock_utils import (
replace_course_urls,
replace_jump_to_id_urls,
......@@ -715,6 +716,7 @@ def get_module_system_for_user(user, student_data, # TODO # pylint: disable=to
"reverification": ReverificationService(),
'proctoring': ProctoringService(),
'credit': CreditService(),
'bookmarks': BookmarksService(user=user),
},
get_user_role=lambda: get_user_role(user, course_id),
descriptor_runtime=descriptor._runtime, # pylint: disable=protected-access
......
......@@ -73,6 +73,7 @@ TEST_DATA_DIR = settings.COMMON_TEST_DATA_ROOT
@XBlock.needs("i18n")
@XBlock.needs("fs")
@XBlock.needs("user")
@XBlock.needs("bookmarks")
class PureXBlock(XBlock):
"""
Pure XBlock to use in tests.
......@@ -1232,6 +1233,7 @@ class ViewInStudioTest(ModuleStoreTestCase):
self.request.user = self.staff_user
self.request.session = {}
self.module = None
self.default_context = {'bookmarked': False, 'username': self.user.username}
def _get_module(self, course_id, descriptor, location):
"""
......@@ -1290,14 +1292,14 @@ class MongoViewInStudioTest(ViewInStudioTest):
def test_view_in_studio_link_studio_course(self):
"""Regular Studio courses should see 'View in Studio' links."""
self.setup_mongo_course()
result_fragment = self.module.render(STUDENT_VIEW)
result_fragment = self.module.render(STUDENT_VIEW, context=self.default_context)
self.assertIn('View Unit in Studio', result_fragment.content)
def test_view_in_studio_link_only_in_top_level_vertical(self):
"""Regular Studio courses should not see 'View in Studio' for child verticals of verticals."""
self.setup_mongo_course()
# Render the parent vertical, then check that there is only a single "View Unit in Studio" link.
result_fragment = self.module.render(STUDENT_VIEW)
result_fragment = self.module.render(STUDENT_VIEW, context=self.default_context)
# The single "View Unit in Studio" link should appear before the first xmodule vertical definition.
parts = result_fragment.content.split('data-block-type="vertical"')
self.assertEqual(3, len(parts), "Did not find two vertical blocks")
......@@ -1308,7 +1310,7 @@ class MongoViewInStudioTest(ViewInStudioTest):
def test_view_in_studio_link_xml_authored(self):
"""Courses that change 'course_edit_method' setting can hide 'View in Studio' links."""
self.setup_mongo_course(course_edit_method='XML')
result_fragment = self.module.render(STUDENT_VIEW)
result_fragment = self.module.render(STUDENT_VIEW, context=self.default_context)
self.assertNotIn('View Unit in Studio', result_fragment.content)
......@@ -1321,19 +1323,19 @@ class MixedViewInStudioTest(ViewInStudioTest):
def test_view_in_studio_link_mongo_backed(self):
"""Mixed mongo courses that are mongo backed should see 'View in Studio' links."""
self.setup_mongo_course()
result_fragment = self.module.render(STUDENT_VIEW)
result_fragment = self.module.render(STUDENT_VIEW, context=self.default_context)
self.assertIn('View Unit in Studio', result_fragment.content)
def test_view_in_studio_link_xml_authored(self):
"""Courses that change 'course_edit_method' setting can hide 'View in Studio' links."""
self.setup_mongo_course(course_edit_method='XML')
result_fragment = self.module.render(STUDENT_VIEW)
result_fragment = self.module.render(STUDENT_VIEW, context=self.default_context)
self.assertNotIn('View Unit in Studio', result_fragment.content)
def test_view_in_studio_link_xml_backed(self):
"""Course in XML only modulestore should not see 'View in Studio' links."""
self.setup_xml_course()
result_fragment = self.module.render(STUDENT_VIEW)
result_fragment = self.module.render(STUDENT_VIEW, context=self.default_context)
self.assertNotIn('View Unit in Studio', result_fragment.content)
......@@ -1861,7 +1863,7 @@ class LMSXBlockServiceBindingTest(ModuleStoreTestCase):
self.request_token = Mock()
@XBlock.register_temp_plugin(PureXBlock, identifier='pure')
@ddt.data("user", "i18n", "fs", "field-data")
@ddt.data("user", "i18n", "fs", "field-data", "bookmarks")
def test_expected_services_exist(self, expected_service):
"""
Tests that the 'user', 'i18n', and 'fs' services are provided by the LMS runtime.
......
......@@ -119,7 +119,7 @@ class SplitTestBase(ModuleStoreTestCase):
content = resp.content
# Assert we see the proper icon in the top display
self.assertIn('<a class="{} inactive progress-0"'.format(self.ICON_CLASSES[user_tag]), content)
self.assertIn('<a class="{} inactive progress-0 nav-item"'.format(self.ICON_CLASSES[user_tag]), content)
# And proper tooltips
for tooltip in self.TOOLTIPS[user_tag]:
self.assertIn(tooltip, content)
......
......@@ -98,6 +98,10 @@ from eventtracking import tracker
import analytics
from courseware.url_helpers import get_redirect_url
from lang_pref import LANGUAGE_KEY
from openedx.core.djangoapps.user_api.preferences.api import get_user_preference
log = logging.getLogger("edx.courseware")
template_imports = {'urllib': urllib}
......@@ -400,6 +404,8 @@ def _index_bulk_op(request, course_key, chapter, section, position):
if survey.utils.must_answer_survey(course, user):
return redirect(reverse('course_survey', args=[unicode(course.id)]))
bookmarks_api_url = reverse('bookmarks')
try:
field_data_cache = FieldDataCache.cache_for_descriptor_descendents(
course_key, user, course, depth=2)
......@@ -414,6 +420,10 @@ def _index_bulk_op(request, course_key, chapter, section, position):
studio_url = get_studio_url(course, 'course')
language_preference = get_user_preference(request.user, LANGUAGE_KEY)
if not language_preference:
language_preference = settings.LANGUAGE_CODE
context = {
'csrf': csrf(request)['csrf_token'],
'accordion': render_accordion(user, request, course, chapter, section, field_data_cache),
......@@ -425,6 +435,8 @@ def _index_bulk_op(request, course_key, chapter, section, position):
'studio_url': studio_url,
'masquerade': masquerade,
'xqa_server': settings.FEATURES.get('XQA_SERVER', "http://your_xqa_server.com"),
'bookmarks_api_url': bookmarks_api_url,
'language_preference': language_preference,
}
now = datetime.now(UTC())
......
......@@ -6,17 +6,16 @@ from django.conf import settings
from .views import UserDetail, UserCourseEnrollmentsList, UserCourseStatus
USERNAME_PATTERN = r'(?P<username>[\w.+-]+)'
urlpatterns = patterns(
'mobile_api.users.views',
url('^' + USERNAME_PATTERN + '$', UserDetail.as_view(), name='user-detail'),
url('^' + settings.USERNAME_PATTERN + '$', UserDetail.as_view(), name='user-detail'),
url(
'^' + USERNAME_PATTERN + '/course_enrollments/$',
'^' + settings.USERNAME_PATTERN + '/course_enrollments/$',
UserCourseEnrollmentsList.as_view(),
name='courseenrollment-detail'
),
url('^{}/course_status_info/{}'.format(USERNAME_PATTERN, settings.COURSE_ID_PATTERN),
url('^{}/course_status_info/{}'.format(settings.USERNAME_PATTERN, settings.COURSE_ID_PATTERN),
UserCourseStatus.as_view(),
name='user-course-status')
)
define(['jquery',
'underscore',
'moment-with-locales',
'teams/js/views/team_card',
'teams/js/models/team'],
function ($, _, TeamCardView, Team) {
function ($, _, moment, TeamCardView, Team) {
'use strict';
describe('TeamCardView', function () {
......@@ -35,6 +36,7 @@ define(['jquery',
};
beforeEach(function () {
moment.locale('en');
view = createTeamCardView();
view.render();
});
......
......@@ -731,3 +731,6 @@ JWT_EXPIRATION = ENV_TOKENS.get('JWT_EXPIRATION', JWT_EXPIRATION)
PROCTORING_BACKEND_PROVIDER = AUTH_TOKENS.get("PROCTORING_BACKEND_PROVIDER", PROCTORING_BACKEND_PROVIDER)
PROCTORING_SETTINGS = ENV_TOKENS.get("PROCTORING_SETTINGS", PROCTORING_SETTINGS)
# Course Content Bookmarks Settings
MAX_BOOKMARKS_PER_COURSE = ENV_TOKENS.get('MAX_BOOKMARKS_PER_COURSE', MAX_BOOKMARKS_PER_COURSE)
......@@ -574,6 +574,7 @@ USAGE_KEY_PATTERN = r'(?P<usage_key_string>(?:i4x://?[^/]+/[^/]+/[^/]+/[^@]+(?:@
ASSET_KEY_PATTERN = r'(?P<asset_key_string>(?:/?c4x(:/)?/[^/]+/[^/]+/[^/]+/[^@]+(?:@[^/]+)?)|(?:[^/]+))'
USAGE_ID_PATTERN = r'(?P<usage_id>(?:i4x://?[^/]+/[^/]+/[^/]+/[^@]+(?:@[^/]+)?)|(?:[^/]+))'
USERNAME_PATTERN = r'(?P<username>[\w.@+-]+)'
############################## EVENT TRACKING #################################
LMS_SEGMENT_KEY = None
......@@ -1906,6 +1907,9 @@ INSTALLED_APPS = (
'xblock_django',
# Bookmarks
'openedx.core.djangoapps.bookmarks',
# programs support
'openedx.core.djangoapps.programs',
......@@ -2651,3 +2655,6 @@ CCX_MAX_STUDENTS_ALLOWED = 200
# financial assistance form
FINANCIAL_ASSISTANCE_MIN_LENGTH = 800
FINANCIAL_ASSISTANCE_MAX_LENGTH = 2500
# Course Content Bookmarks Settings
MAX_BOOKMARKS_PER_COURSE = 100
;(function (define) {
'use strict';
define([
'js/bookmarks/views/bookmarks_list_button'
],
function(BookmarksListButton) {
return function() {
return new BookmarksListButton();
};
}
);
}).call(this, define || RequireJS.define);
;(function (define) {
'use strict';
define(['backbone', 'common/js/components/collections/paging_collection', 'js/bookmarks/models/bookmark'],
function (Backbone, PagingCollection, BookmarkModel) {
return PagingCollection.extend({
initialize: function(options) {
PagingCollection.prototype.initialize.call(this);
this.url = options.url;
this.server_api = _.extend(
{
course_id: function () { return encodeURIComponent(options.course_id); },
fields : function () { return encodeURIComponent('display_name,path'); }
},
PagingCollection.prototype.server_api
);
delete this.server_api.sort_order; // Sort order is not specified for the Bookmark API
},
model: BookmarkModel,
url: function() {
return this.url;
}
});
});
})(define || RequireJS.define);
;(function (define) {
'use strict';
define(['backbone'], function (Backbone) {
return Backbone.Model.extend({
idAttribute: 'id',
defaults: {
course_id: '',
usage_id: '',
display_name: '',
path: [],
created: ''
},
blockUrl: function () {
return '/courses/' + this.get('course_id') + '/jump_to/' + this.get('usage_id');
}
});
});
})(define || RequireJS.define);
;(function (define, undefined) {
'use strict';
define(['gettext', 'jquery', 'underscore', 'backbone', 'js/views/message_banner'],
function (gettext, $, _, Backbone, MessageBannerView) {
return Backbone.View.extend({
errorMessage: gettext('An error has occurred. Please try again.'),
srAddBookmarkText: gettext('Click to add'),
srRemoveBookmarkText: gettext('Click to remove'),
events: {
'click': 'toggleBookmark'
},
showBannerInterval: 5000, // time in ms
initialize: function (options) {
this.apiUrl = options.apiUrl;
this.bookmarkId = options.bookmarkId;
this.bookmarked = options.bookmarked;
this.usageId = options.usageId;
this.setBookmarkState(this.bookmarked);
},
toggleBookmark: function(event) {
event.preventDefault();
if (this.$el.hasClass('bookmarked')) {
this.removeBookmark();
} else {
this.addBookmark();
}
},
addBookmark: function() {
var view = this;
$.ajax({
data: {usage_id: view.usageId},
type: "POST",
url: view.apiUrl,
dataType: 'json',
success: function () {
view.$el.trigger('bookmark:add');
view.setBookmarkState(true);
},
error: function (jqXHR) {
var response = jqXHR.responseText ? JSON.parse(jqXHR.responseText) : '';
var userMessage = response ? response.user_message : '';
view.showError(userMessage);
}
});
},
removeBookmark: function() {
var view = this;
var deleteUrl = view.apiUrl + view.bookmarkId + '/';
$.ajax({
type: "DELETE",
url: deleteUrl,
success: function () {
view.$el.trigger('bookmark:remove');
view.setBookmarkState(false);
},
error: function() {
view.showError();
}
});
},
setBookmarkState: function(bookmarked) {
if (bookmarked) {
this.$el.addClass('bookmarked');
this.$el.attr('aria-pressed', 'true');
this.$el.find('.bookmark-sr').text(this.srRemoveBookmarkText);
} else {
this.$el.removeClass('bookmarked');
this.$el.attr('aria-pressed', 'false');
this.$el.find('.bookmark-sr').text(this.srAddBookmarkText);
}
},
showError: function (errorText) {
var errorMsg = errorText || this.errorMessage;
if (!this.messageView) {
this.messageView = new MessageBannerView({
el: $('.message-banner'),
type: 'error'
});
}
this.messageView.showMessage(errorMsg);
// Hide message automatically after some interval
setTimeout(_.bind(function () {
this.messageView.hideMessage();
}, this), this.showBannerInterval);
}
});
});
}).call(this, define || RequireJS.define);
;(function (define, undefined) {
'use strict';
define(['gettext', 'jquery', 'underscore', 'backbone', 'logger', 'moment',
'common/js/components/views/paging_header', 'common/js/components/views/paging_footer',
'text!templates/bookmarks/bookmarks-list.underscore'
],
function (gettext, $, _, Backbone, Logger, _moment,
PagingHeaderView, PagingFooterView, BookmarksListTemplate) {
var moment = _moment || window.moment;
return Backbone.View.extend({
el: '.courseware-results',
coursewareContentEl: '#course-content',
coursewareResultsWrapperEl: '.courseware-results-wrapper',
errorIcon: '<i class="fa fa-fw fa-exclamation-triangle message-error" aria-hidden="true"></i>',
loadingIcon: '<i class="fa fa-fw fa-spinner fa-pulse message-in-progress" aria-hidden="true"></i>',
errorMessage: gettext('An error has occurred. Please try again.'),
loadingMessage: gettext('Loading'),
defaultPage: 1,
events : {
'click .bookmarks-results-list-item': 'visitBookmark'
},
initialize: function (options) {
this.template = _.template(BookmarksListTemplate);
this.loadingMessageView = options.loadingMessageView;
this.errorMessageView = options.errorMessageView;
this.langCode = $(this.el).data('langCode');
this.pagingHeaderView = new PagingHeaderView({collection: this.collection});
this.pagingFooterView = new PagingFooterView({collection: this.collection});
this.listenTo(this.collection, 'page_changed', this.render);
_.bindAll(this, 'render', 'humanFriendlyDate');
},
render: function () {
var data = {
bookmarksCollection: this.collection,
humanFriendlyDate: this.humanFriendlyDate
};
this.$el.html(this.template(data));
this.pagingHeaderView.setElement(this.$('.paging-header')).render();
this.pagingFooterView.setElement(this.$('.paging-footer')).render();
this.delegateEvents();
return this;
},
showBookmarks: function () {
var view = this;
this.hideErrorMessage();
this.showBookmarksContainer();
this.collection.goTo(this.defaultPage).done(function () {
view.render();
view.focusBookmarksElement();
}).fail(function () {
view.showErrorMessage();
});
},
visitBookmark: function (event) {
var bookmarkedComponent = $(event.currentTarget);
var bookmark_id = bookmarkedComponent.data('bookmarkId');
var component_usage_id = bookmarkedComponent.data('usageId');
var component_type = bookmarkedComponent.data('componentType');
Logger.log(
'edx.bookmark.accessed',
{
bookmark_id: bookmark_id,
component_type: component_type,
component_usage_id: component_usage_id
}
).always(function () {
window.location.href = event.currentTarget.pathname;
});
},
/**
* Convert ISO 8601 formatted date into human friendly format. e.g, `2014-05-23T14:00:00Z` to `May 23, 2014`
* @param {String} isoDate - ISO 8601 formatted date string.
*/
humanFriendlyDate: function (isoDate) {
moment.locale(this.langCode);
return moment(isoDate).format('LL');
},
areBookmarksVisible: function () {
return this.$('#my-bookmarks').is(":visible");
},
hideBookmarks: function () {
this.$el.hide();
$(this.coursewareResultsWrapperEl).hide();
$(this.coursewareContentEl).css( 'display', 'table-cell');
},
showBookmarksContainer: function () {
$(this.coursewareContentEl).hide();
// Empty el if it's not empty to get the clean state.
this.$el.html('');
this.$el.show();
$(this.coursewareResultsWrapperEl).css('display', 'table-cell');
},
showLoadingMessage: function () {
this.loadingMessageView.showMessage(this.loadingMessage, this.loadingIcon);
},
hideLoadingMessage: function () {
this.loadingMessageView.hideMessage();
},
showErrorMessage: function () {
this.errorMessageView.showMessage(this.errorMessage, this.errorIcon);
},
hideErrorMessage: function () {
this.errorMessageView.hideMessage();
},
focusBookmarksElement: function () {
this.$('#my-bookmarks').focus();
}
});
});
}).call(this, define || RequireJS.define);
;(function (define, undefined) {
'use strict';
define(['gettext', 'jquery', 'underscore', 'backbone', 'js/bookmarks/views/bookmarks_list',
'js/bookmarks/collections/bookmarks', 'js/views/message_banner'],
function (gettext, $, _, Backbone, BookmarksListView, BookmarksCollection, MessageBannerView) {
return Backbone.View.extend({
el: '.courseware-bookmarks-button',
loadingMessageElement: '#loading-message',
errorMessageElement: '#error-message',
events: {
'click .bookmarks-list-button': 'toggleBookmarksListView'
},
initialize: function () {
var bookmarksCollection = new BookmarksCollection(
{
course_id: $('.courseware-results').data('courseId'),
url: $(".courseware-bookmarks-button").data('bookmarksApiUrl')
}
);
bookmarksCollection.bootstrap();
this.bookmarksListView = new BookmarksListView(
{
collection: bookmarksCollection,
loadingMessageView: new MessageBannerView({el: $(this.loadingMessageElement)}),
errorMessageView: new MessageBannerView({el: $(this.errorMessageElement)})
}
);
},
toggleBookmarksListView: function () {
if (this.bookmarksListView.areBookmarksVisible()) {
this.bookmarksListView.hideBookmarks();
this.$('.bookmarks-list-button').attr('aria-pressed', 'false');
this.$('.bookmarks-list-button').removeClass('is-active').addClass('is-inactive');
} else {
this.bookmarksListView.showBookmarks();
this.$('.bookmarks-list-button').attr('aria-pressed', 'true');
this.$('.bookmarks-list-button').removeClass('is-inactive').addClass('is-active');
}
}
});
});
}).call(this, define || RequireJS.define);
<div class="message-banner" aria-live="polite"></div>
<div class="xblock xblock-student_view xblock-student_view-vertical xblock-initialized">
<div class="bookmark-button-wrapper">
<button class="btn bookmark-button"
aria-pressed="false"
data-bookmark-id="bilbo,usage_1">
<span class="sr bookmark-sr"></span>&nbsp;
Bookmark
</button>
</div>
</div>
<div class="courseware-bookmarks-button" data-bookmarks-api-url="/api/bookmarks/v1/bookmarks/">
<button type="button" class="bookmarks-list-button is-inactive" aria-pressed="false">
Bookmarks
</button>
</div>
<section class="courseware-results-wrapper">
<div id="loading-message" aria-live="assertive" aria-relevant="all"></div>
<div id="error-message" aria-live="polite"></div>
<div class="courseware-results" data-course-id="a/b/c" data-lang-code="en"></div>
</section>
......@@ -37,8 +37,7 @@ define([
}));
this.renderItems();
this.$el.find(this.spinner).hide();
this.$contentElement.hide();
this.$el.show();
this.showResults();
return this;
},
......@@ -71,16 +70,29 @@ define([
this.$contentElement.show();
},
showLoadingMessage: function () {
this.$el.html(this.loadingTemplate());
showResults: function() {
this.$el.show();
this.$contentElement.hide();
},
showLoadingMessage: function () {
this.doCleanup();
this.$el.html(this.loadingTemplate());
this.showResults();
},
showErrorMessage: function () {
this.$el.html(this.errorTemplate());
this.$el.show();
this.$contentElement.hide();
this.showResults();
},
doCleanup: function () {
// Empty any loading/error message and empty the el
// Bookmarks share the same container element, So we are doing
// this to ensure that elements are in clean/initial state
$('#loading-message').html('');
$('#error-message').html('');
this.$el.html('');
},
loadNext: function (event) {
......
......@@ -8,15 +8,27 @@ define([
return SearchResultsView.extend({
el: '#courseware-search-results',
el: '.courseware-results',
contentElement: '#course-content',
coursewareResultsWrapperElement: '.courseware-results-wrapper',
resultsTemplateId: '#course_search_results-tpl',
loadingTemplateId: '#search_loading-tpl',
errorTemplateId: '#search_error-tpl',
events: {
'click .search-load-next': 'loadNext',
},
SearchItemView: CourseSearchItemView
SearchItemView: CourseSearchItemView,
clear: function () {
SearchResultsView.prototype.clear.call(this);
$(this.coursewareResultsWrapperElement).hide();
this.$contentElement.css('display', 'table-cell');
},
showResults: function () {
SearchResultsView.prototype.showResults.call(this);
$(this.coursewareResultsWrapperElement).css('display', 'table-cell');
}
});
......
define(['backbone', 'jquery', 'underscore', 'common/js/spec_helpers/ajax_helpers',
'common/js/spec_helpers/template_helpers', 'js/bookmarks/views/bookmark_button'
],
function (Backbone, $, _, AjaxHelpers, TemplateHelpers, BookmarkButtonView) {
'use strict';
describe("bookmarks.button", function () {
var timerCallback;
var API_URL = 'bookmarks/api/v1/bookmarks/';
beforeEach(function () {
loadFixtures('js/fixtures/bookmarks/bookmark_button.html');
TemplateHelpers.installTemplates(
[
'templates/fields/message_banner'
]
);
timerCallback = jasmine.createSpy('timerCallback');
jasmine.Clock.useMock();
});
var createBookmarkButtonView = function(isBookmarked) {
return new BookmarkButtonView({
el: '.bookmark-button',
bookmarked: isBookmarked,
bookmarkId: 'bilbo,usage_1',
usageId: 'usage_1',
apiUrl: API_URL
});
};
var verifyBookmarkButtonState = function (view, bookmarked) {
if (bookmarked) {
expect(view.$el).toHaveAttr('aria-pressed', 'true');
expect(view.$el).toHaveClass('bookmarked');
expect(view.$el.find('.bookmark-sr').text()).toBe('Click to remove');
} else {
expect(view.$el).toHaveAttr('aria-pressed', 'false');
expect(view.$el).not.toHaveClass('bookmarked');
expect(view.$el.find('.bookmark-sr').text()).toBe('Click to add');
}
expect(view.$el.data('bookmarkId')).toBe('bilbo,usage_1');
};
it("rendered correctly ", function () {
var view = createBookmarkButtonView(false);
verifyBookmarkButtonState(view, false);
// with bookmarked true
view = createBookmarkButtonView(true);
verifyBookmarkButtonState(view, true);
});
it("bookmark/un-bookmark the block correctly", function () {
var addBookmarkedData = {
bookmarked: true,
handler: 'removeBookmark',
event: 'bookmark:remove',
method: 'DELETE',
url: API_URL + 'bilbo,usage_1/',
body: null
};
var removeBookmarkData = {
bookmarked: false,
handler: 'addBookmark',
event: 'bookmark:add',
method: 'POST',
url: API_URL,
body: 'usage_id=usage_1'
};
var requests = AjaxHelpers.requests(this);
var bookmarkedData = [[addBookmarkedData, removeBookmarkData], [removeBookmarkData, addBookmarkedData]];
_.each(bookmarkedData, function(actionsData) {
var firstActionData = actionsData[0];
var secondActionData = actionsData[1];
var bookmarkButtonView = createBookmarkButtonView(firstActionData.bookmarked);
verifyBookmarkButtonState(bookmarkButtonView, firstActionData.bookmarked);
spyOn(bookmarkButtonView, firstActionData.handler).andCallThrough();
spyOnEvent(bookmarkButtonView.$el, firstActionData.event);
bookmarkButtonView.$el.click();
AjaxHelpers.expectRequest(
requests, firstActionData.method,
firstActionData.url,
firstActionData.body
);
expect(bookmarkButtonView[firstActionData.handler]).toHaveBeenCalled();
AjaxHelpers.respondWithJson(requests, {});
expect(firstActionData.event).toHaveBeenTriggeredOn(bookmarkButtonView.$el);
bookmarkButtonView[firstActionData.handler].reset();
verifyBookmarkButtonState(bookmarkButtonView, secondActionData.bookmarked);
spyOn(bookmarkButtonView, secondActionData.handler).andCallThrough();
spyOnEvent(bookmarkButtonView.$el, secondActionData.event);
bookmarkButtonView.$el.click();
AjaxHelpers.expectRequest(
requests,
secondActionData.method,
secondActionData.url,
secondActionData.body
);
expect(bookmarkButtonView[secondActionData.handler]).toHaveBeenCalled();
AjaxHelpers.respondWithJson(requests, {});
expect(secondActionData.event).toHaveBeenTriggeredOn(bookmarkButtonView.$el);
verifyBookmarkButtonState(bookmarkButtonView, firstActionData.bookmarked);
bookmarkButtonView.undelegateEvents();
});
});
it("shows an error message for HTTP 500", function () {
var requests = AjaxHelpers.requests(this),
$messageBanner = $('.message-banner'),
bookmarkButtonView = createBookmarkButtonView(false);
bookmarkButtonView.$el.click();
AjaxHelpers.respondWithError(requests);
expect($messageBanner.text().trim()).toBe(bookmarkButtonView.errorMessage);
// For bookmarked button.
bookmarkButtonView = createBookmarkButtonView(true);
bookmarkButtonView.$el.click();
AjaxHelpers.respondWithError(requests);
expect($messageBanner.text().trim()).toBe(bookmarkButtonView.errorMessage);
});
it('removes error message after 5 seconds', function () {
var requests = AjaxHelpers.requests(this),
$messageBanner = $('.message-banner'),
bookmarkButtonView = createBookmarkButtonView(false);
bookmarkButtonView.$el.click();
AjaxHelpers.respondWithError(requests);
expect($messageBanner.text().trim()).toBe(bookmarkButtonView.errorMessage);
jasmine.Clock.tick(5001);
expect($messageBanner.text().trim()).toBe('');
});
});
});
......@@ -86,6 +86,13 @@
// Discussion classes loaded explicitly until they are converted to use RequireJS
'DiscussionModuleView': 'xmodule_js/common_static/coffee/src/discussion/discussion_module_view',
'js/bookmarks/collections/bookmarks': 'js/bookmarks/collections/bookmarks',
'js/bookmarks/models/bookmark': 'js/bookmarks/models/bookmark',
'js/bookmarks/views/bookmarks_list_button': 'js/bookmarks/views/bookmarks_list_button',
'js/bookmarks/views/bookmarks_list': 'js/bookmarks/views/bookmarks_list',
'js/bookmarks/views/bookmark_button': 'js/bookmarks/views/bookmark_button',
'js/views/message_banner': 'js/views/message_banner',
// edxnotes
'annotator_1.2.9': 'xmodule_js/common_static/js/vendor/edxnotes/annotator-full.min',
......@@ -733,7 +740,10 @@
'lms/include/teams/js/spec/views/topic_teams_spec.js',
'lms/include/teams/js/spec/views/topics_spec.js',
'lms/include/teams/js/spec/views/team_profile_header_actions_spec.js',
'lms/include/js/spec/financial-assistance/financial_assistance_form_view_spec.js'
'lms/include/js/spec/financial-assistance/financial_assistance_form_view_spec.js',
'lms/include/js/spec/bookmarks/bookmarks_list_view_spec.js',
'lms/include/js/spec/bookmarks/bookmark_button_view_spec.js',
'lms/include/js/spec/views/message_banner_spec.js'
]);
}).call(this, requirejs, define);
......@@ -414,7 +414,7 @@ define([
function returnsToContent () {
this.resultsView.clear();
expect(this.resultsView.$contentElement).toBeVisible();
expect(this.resultsView.$contentElement).toHaveCss({'display': this.contentElementDisplayValue});
expect(this.resultsView.$el).toBeHidden();
expect(this.resultsView.$el).toBeEmpty();
}
......@@ -487,7 +487,7 @@ define([
function beforeEachHelper(SearchResultsView) {
appendSetFixtures(
'<section id="courseware-search-results"></section>' +
'<div class="courseware-results"></div>' +
'<section id="course-content"></section>' +
'<section id="dashboard-search-results"></section>' +
'<section id="my-courses"></section>'
......@@ -518,6 +518,7 @@ define([
describe('CourseSearchResultsView', function () {
beforeEach(function() {
beforeEachHelper.call(this, CourseSearchResultsView);
this.contentElementDisplayValue = 'table-cell';
});
it('shows loading message', showsLoadingMessage);
it('shows error message', showsErrorMessage);
......@@ -532,6 +533,7 @@ define([
describe('DashSearchResultsView', function () {
beforeEach(function() {
beforeEachHelper.call(this, DashSearchResultsView);
this.contentElementDisplayValue = 'block';
});
it('shows loading message', showsLoadingMessage);
it('shows error message', showsErrorMessage);
......@@ -613,13 +615,13 @@ define([
$('.cancel-button').trigger('click');
AjaxHelpers.skipResetRequest(requests);
// there should be no results
expect(this.$contentElement).toBeVisible();
expect(this.$contentElement).toHaveCss({'display': this.contentElementDisplayValue});
expect(this.$searchResults).toBeHidden();
}
function clearsResults () {
$('.cancel-button').trigger('click');
expect(this.$contentElement).toBeVisible();
expect(this.$contentElement).toHaveCss({'display': this.contentElementDisplayValue});
expect(this.$searchResults).toBeHidden();
}
......@@ -673,7 +675,7 @@ define([
beforeEach(function () {
loadFixtures('js/fixtures/search/course_search_form.html');
appendSetFixtures(
'<section id="courseware-search-results"></section>' +
'<div class="courseware-results"></div>' +
'<section id="course-content"></section>'
);
loadTemplates.call(this);
......@@ -682,7 +684,8 @@ define([
CourseSearchFactory(courseId);
spyOn(Backbone.history, 'navigate');
this.$contentElement = $('#course-content');
this.$searchResults = $('#courseware-search-results');
this.contentElementDisplayValue = 'table-cell';
this.$searchResults = $('.courseware-results');
});
it('shows loading message on search', showsLoadingMessage);
......@@ -709,6 +712,7 @@ define([
spyOn(Backbone.history, 'navigate');
this.$contentElement = $('#my-courses');
this.contentElementDisplayValue = 'block';
this.$searchResults = $('#dashboard-search-results');
});
......@@ -749,4 +753,4 @@ define([
});
});
});
});
\ No newline at end of file
define(['backbone', 'jquery', 'underscore', 'js/views/message_banner'
define(['backbone', 'jquery', 'underscore',
'common/js/spec_helpers/template_helpers', 'js/views/message_banner'
],
function (Backbone, $, _, MessageBannerView) {
function (Backbone, $, _, TemplateHelpers, MessageBannerView) {
'use strict';
describe("MessageBannerView", function () {
......
......@@ -86,7 +86,7 @@
required: true,
title: gettext('Country or Region'),
valueAttribute: 'country',
options: fieldsData['country']['options'],
options: fieldsData.country.options,
persistChanges: true
})
}
......@@ -118,7 +118,7 @@
model: userAccountModel,
title: gettext('Year of Birth'),
valueAttribute: 'year_of_birth',
options: fieldsData['year_of_birth']['options'],
options: fieldsData.year_of_birth.options,
persistChanges: true
})
},
......
......@@ -114,6 +114,8 @@ fixture_paths:
- common/templates
- teams/templates
- support/templates
- js/fixtures/bookmarks
- templates/bookmarks
requirejs:
paths:
......
......@@ -33,6 +33,7 @@
'teams/js/teams_tab_factory',
'support/js/certificates_factory',
'support/js/enrollment_factory',
'js/bookmarks/bookmarks_factory'
]),
/**
......
......@@ -54,6 +54,7 @@
@import 'views/homepage';
@import 'views/support';
@import "views/financial-assistance";
@import 'views/bookmarks';
@import 'course/auto-cert';
// app - discussion
......
......@@ -101,6 +101,24 @@
}
}
// light button reset
%ui-clear-button {
background: none;
border-radius: ($baseline/4);
box-shadow: none;
text-shadow: none;
&:hover {
background-image: none;
box-shadow: none;
}
&:focus,
&:active {
box-shadow: none;
}
}
// removes list styling/spacing when using uls, ols for navigation and less content-centric cases
%ui-no-list {
list-style: none;
......
......@@ -97,10 +97,15 @@ html.video-fullscreen {
}
// TO-DO should this be content wrapper?
div.course-wrapper {
.course-wrapper {
position: relative;
section.course-content {
.courseware-results-wrapper {
display: none;
}
.course-content,
.courseware-results-wrapper {
@extend .content;
padding: ($baseline*2) 3%; // percent allows for smaller padding on mobile
line-height: 1.6;
......@@ -403,6 +408,13 @@ div.course-wrapper {
}
}
.sequence .path {
@extend %t-copy-sub1;
margin-top: -($baseline);
margin-bottom: $baseline;
color: $gray;
}
div#seq_content {
h1 {
background: none;
......@@ -649,3 +661,5 @@ section.self-assessment {
font-weight: bold;
}
}
......@@ -54,16 +54,7 @@
}
.nav-label {
/* This wasn't working for me, so I directly copied the rule
@extend %cont-text-sr; */
border: 0;
clip: rect(0 0 0 0);
height: 1px;
margin: -1px;
overflow: hidden;
padding: 0;
position: absolute;
width: 1px;
@extend %text-sr;
}
.pagination-form,
......@@ -105,16 +96,7 @@
.page-number-label,
.submit-pagination-form {
/* This wasn't working for me, so I directly copied the rule
@extend %cont-text-sr; */
border: 0;
clip: rect(0 0 0 0);
height: 1px;
margin: -1px;
overflow: hidden;
padding: 0;
position: absolute;
width: 1px;
@extend %text-sr;
}
.page-number-input {
......@@ -142,3 +124,21 @@
}
}
}
// styles for search/pagination metadata and sorting
.listing-tools {
@extend %t-copy-sub1;
color: $gray-d1;
label { // override
color: inherit;
font-size: inherit;
cursor: auto;
}
.listing-sort-select {
@extend %t-copy-sub1;
@extend %t-regular;
border: 0;
}
}
......@@ -2,7 +2,6 @@
@include box-sizing(border-box);
position: relative;
padding: ($baseline/4);
.search-field-wrapper {
position: relative;
......@@ -51,8 +50,10 @@
display: none;
.search-info {
margin-bottom: ($baseline);
border-bottom: 4px solid $border-color-l4;
padding-bottom: $baseline;
padding-bottom: ($baseline/2);
.search-count {
@include float(right);
color: $gray-l1;
......@@ -124,7 +125,7 @@
}
.courseware-search-bar {
box-shadow: 0 1px 0 $white inset, 0 -1px 0 $shadow-l1 inset;
width: flex-grid(7);
}
......@@ -171,6 +172,3 @@
}
}
.courseware-search-results {
padding: ($baseline*2);
}
// Rules for placing bookmarks and search button side by side
.wrapper-course-modes {
border-bottom: 1px solid $gray-l3;
padding: ($baseline/4);
> div {
@include box-sizing(border-box);
display: inline-block;
}
}
// Rules for Bookmarks Button
.courseware-bookmarks-button {
width: flex-grid(5);
vertical-align: top;
.bookmarks-list-button {
@extend %ui-clear-button;
// set styles
@extend %btn-pl-default-base;
@include font-size(13);
width: 100%;
padding: ($baseline/4) ($baseline/2);
&:before {
content: "\f02e";
font-family: FontAwesome;
}
&.is-active {
background-color: lighten($action-primary-bg,10%);
color: $white;
}
}
}
// Rules for Bookmarks Results Header
.bookmarks-results-header {
@extend %t-title4;
letter-spacing: 0;
text-transform: none;
margin-bottom: ($baseline/2);
}
// Rules for Bookmarks Results
.bookmarks-results-list {
padding-top: ($baseline/2);
.bookmarks-results-list-item {
@include padding(0, $baseline, ($baseline/4), $baseline);
display: block;
border: 1px solid $gray-l4;
margin-bottom: $baseline;
&:hover {
border-color: $m-blue;
.list-item-breadcrumbtrail {
color: $blue;
}
}
.icon {
@extend %t-icon6;
}
}
.results-list-item-view {
@include float(right);
margin-top: $baseline;
}
.list-item-date {
@extend %t-copy-sub2;
margin-top: ($baseline/4);
color: $gray;
}
.bookmarks-results-list-item:before {
content: "\f02e";
position: relative;
top: -7px;
font-family: FontAwesome;
color: $m-blue;
}
.list-item-content {
overflow: hidden;
}
.list-item-left-section {
display: inline-block;
vertical-align: middle;
width: 90%;
}
.list-item-right-section {
display: inline-block;
vertical-align: middle;
.fa-arrow-right {
@include rtl {
@include transform(rotate(180deg));
}
}
}
}
// Rules for empty bookmarks list
.bookmarks-empty {
margin-top: $baseline;
border: 1px solid $gray-l4;
padding: $baseline;
background-color: $gray-l6;
}
.bookmarks-empty-header {
@extend %t-title5;
margin-bottom: ($baseline/2);
}
.bookmarks-empty-detail {
@extend %t-copy-sub1;
}
// Rules for bookmark icon shown on each sequence nav item
.course-content {
.bookmark-icon.bookmarked {
top: -3px;
position: absolute;
left: ($baseline/4);
}
// Rules for bookmark button's different styles
.bookmark-button-wrapper {
text-align: right;
margin-bottom: 10px;
}
.bookmark-button {
@extend %ui-clear-button;
@extend %btn-pl-default-base;
@include font-size(13);
padding: ($baseline/4) ($baseline/2);
&:before {
content: "\f02e";
font-family: FontAwesome;
}
&.is-active {
background-color: lighten($action-primary-bg,10%);
color: $white;
}
&.bookmarked {
background-color: lighten($action-primary-bg,10%);
color: $white;
&:before {
content: "\f097";
font-family: FontAwesome;
}
}
}
}
......@@ -177,21 +177,7 @@
}
.listing-tools {
@extend %t-copy-sub1;
margin: ($baseline/10) $baseline;
color: $gray-d1;
label { // override
color: inherit;
font-size: inherit;
cursor: auto;
}
.listing-sort-select {
@extend %t-copy-sub1;
@extend %t-regular;
border: 0;
}
}
// reset general ul styles
......
<%! from django.utils.translation import ugettext as _ %>
<%page args="bookmark_id, is_bookmarked" />
<div class="bookmark-button-wrapper">
<button class="btn bookmark-button ${"bookmarked" if is_bookmarked else ""}"
aria-pressed="${"true" if is_bookmarked else "false"}"
data-bookmark-id="${bookmark_id}">
<span class="sr bookmark-sr">${_("Click to remove") if is_bookmarked else _("Click to add")}</span>&nbsp;
${_("Bookmark")}
</button>
</div>
<div id="my-bookmarks" class="sr-is-focusable" tabindex="-1"></div>
<h2 class="bookmarks-results-header"><%= gettext("My Bookmarks") %></h2>
<% if (bookmarksCollection.length) { %>
<div class="paging-header"></div>
<div class='bookmarks-results-list'>
<% bookmarksCollection.each(function(bookmark, index) { %>
<a class="bookmarks-results-list-item" href="<%= bookmark.blockUrl() %>" aria-labelledby="bookmark-link-<%= index %>" data-bookmark-id="<%= bookmark.get('id') %>" data-component-type="<%= bookmark.get('block_type') %>" data-usage-id="<%= bookmark.get('usage_id') %>" aria-describedby="bookmark-type-<%= index %> bookmark-date-<%= index %>">
<div class="list-item-content">
<div class="list-item-left-section">
<h3 id="bookmark-link-<%= index %>" class="list-item-breadcrumbtrail"> <%= _.pluck(bookmark.get('path'), 'display_name').concat([bookmark.get('display_name')]).join(' <i class="icon fa fa-caret-right" aria-hidden="true"></i><span class="sr">-</span> ') %> </h3>
<p id="bookmark-date-<%= index %>" class="list-item-date"> <%= gettext("Bookmarked on") %> <%= humanFriendlyDate(bookmark.get('created')) %> </p>
</div>
<p id="bookmark-type-<%= index %>" class="list-item-right-section">
<span aria-hidden="true"><%= gettext("View") %></span>
<i class="icon fa fa-arrow-right" aria-hidden="true"></i>
</p>
</div>
</a>
<% }); %>
</div>
<div class="paging-footer"></div>
<% } else {%>
<div class="bookmarks-empty" tabindex="0">
<div class="bookmarks-empty-header">
<i class="icon fa fa-bookmark-o bookmarks-empty-header-icon" aria-hidden="true"></i>
<%= gettext("You have not bookmarked any courseware pages yet.") %>
<br>
</div>
<div class="bookmarks-empty-detail">
<span class="bookmarks-empty-detail-title">
<%= gettext("Use bookmarks to help you easily return to courseware pages. To bookmark a page, select Bookmark in the upper right corner of that page. To see a list of all your bookmarks, select Bookmarks in the upper left corner of any courseware page.") %>
</span>
</div>
</div>
<% } %>
......@@ -24,6 +24,7 @@ ${page_title_breadcrumbs(course_name())}
</title></%block>
<%block name="header_extras">
% for template_name in ["image-modal"]:
<script type="text/template" id="${template_name}-tpl">
<%static:include path="common/templates/${template_name}.underscore" />
......@@ -73,11 +74,15 @@ ${page_title_breadcrumbs(course_name())}
<%static:js group='discussion'/>
% if settings.FEATURES.get('ENABLE_COURSEWARE_SEARCH'):
<%static:require_module module_name="js/search/course/course_search_factory" class_name="CourseSearchFactory">
var courseId = $('#courseware-search-results').data('courseId');
var courseId = $('.courseware-results').data('courseId');
CourseSearchFactory(courseId);
</%static:require_module>
% endif
<%static:require_module module_name="js/bookmarks/bookmarks_factory" class_name="BookmarksFactory">
BookmarksFactory();
</%static:require_module>
<%include file="../discussion/_js_body_dependencies.html" />
% if staff_access:
<%include file="xqa_interface.html"/>
......@@ -112,6 +117,8 @@ ${fragment.foot_html()}
</%block>
<div class="message-banner" aria-live="polite"></div>
% if default_tab:
<%include file="/courseware/course_navigation.html" />
% else:
......@@ -123,22 +130,33 @@ ${fragment.foot_html()}
% if disable_accordion is UNDEFINED or not disable_accordion:
<div class="course-index">
% if settings.FEATURES.get('ENABLE_COURSEWARE_SEARCH'):
<div id="courseware-search-bar" class="search-bar courseware-search-bar" role="search" aria-label="Course">
<form>
<label for="course-search-input" class="sr">${_('Course Search')}</label>
<div class="search-field-wrapper">
<input id="course-search-input" type="text" class="search-field"/>
<button type="submit" class="search-button">
${_('search')} <i class="icon fa fa-search" aria-hidden="true"></i>
</button>
<button type="button" class="cancel-button" aria-label="${_('Clear search')}">
<i class="icon fa fa-remove" aria-hidden="true"></i>
<div class="wrapper-course-modes">
<div class="courseware-bookmarks-button" data-bookmarks-api-url="${bookmarks_api_url}">
<button type="button" class="bookmarks-list-button is-inactive" aria-pressed="false">
${_('Bookmarks')}
</button>
</div>
% if settings.FEATURES.get('ENABLE_COURSEWARE_SEARCH'):
<div id="courseware-search-bar" class="search-bar courseware-search-bar" role="search" aria-label="Course">
<form>
<label for="course-search-input" class="sr">${_('Course Search')}</label>
<div class="search-field-wrapper">
<input id="course-search-input" type="text" class="search-field"/>
<button type="submit" class="search-button">
${_('search')} <i class="icon fa fa-search" aria-hidden="true"></i>
</button>
<button type="button" class="cancel-button" aria-label="${_('Clear search')}">
<i class="icon fa fa-remove" aria-hidden="true"></i>
</button>
</div>
</form>
</div>
</form>
</div>
% endif
% endif
</div>
<div class="accordion">
<nav class="course-navigation" aria-label="${_('Course')}">
......@@ -185,10 +203,13 @@ ${fragment.foot_html()}
${fragment.body_html()}
</section>
% if settings.FEATURES.get('ENABLE_COURSEWARE_SEARCH'):
<section id="courseware-search-results" class="search-results courseware-search-results" data-course-id="${course.id}">
</section>
% endif
<section class="courseware-results-wrapper">
<div id="loading-message" aria-live="polite" aria-relevant="all"></div>
<div id="error-message" aria-live="polite"></div>
<div class="courseware-results search-results" data-course-id="${course.id}" data-lang-code="${language_preference}"></div>
</section>
</div>
</div>
<div class="container-footer">
......
<%! from django.utils.translation import ugettext as _ %>
<div id="sequence_${element_id}" class="sequence" data-id="${item_id}" data-position="${position}" data-ajax-url="${ajax_url}" >
<div class="path"></div>
<div class="sequence-nav">
<button class="sequence-nav-button button-previous">
<span class="icon fa fa-chevron-prev" aria-hidden="true"></span><span class="sr">${_('Previous')}</span>
......@@ -13,16 +14,18 @@
## implementation note: will need to figure out how to handle combining detail
## statuses of multiple modules in js.
<li>
<a class="seq_${item['type']} inactive progress-${item['progress_status']}"
<a class="seq_${item['type']} inactive progress-${item['progress_status']} nav-item"
data-id="${item['id']}"
data-element="${idx+1}"
href="javascript:void(0);"
data-page-title="${item['page_title']|h}"
data-path="${item['path']}"
aria-controls="seq_contents_${idx}"
id="tab_${idx}"
tabindex="0">
<i class="icon fa seq_${item['type']}" aria-hidden="true"></i>
<p><span class="sr">${item['type']}</span>${item['title']}</p>
<i class="fa fa-fw fa-bookmark bookmark-icon ${"is-hidden" if not item['bookmarked'] else "bookmarked"}" aria-hidden="true"></i>
<p><span class="sr">${item['type']}</span> ${item['title']}<span class="sr bookmark-icon-sr">&nbsp;${_("Bookmarked") if item['bookmarked'] else ""}</span></p>
</a>
</li>
% endfor
......
% if show_bookmark_button:
<%include file='bookmark_button.html' args="bookmark_id=bookmark_id, is_bookmarked=bookmarked"/>
% endif
<div class="vert-mod">
% for idx, item in enumerate(items):
<div class="vert vert-${idx}" data-id="${item['id']}">
......
......@@ -89,6 +89,9 @@ urlpatterns = (
# User API endpoints
url(r'^api/user/', include('openedx.core.djangoapps.user_api.urls')),
# Bookmarks API endpoints
url(r'^api/bookmarks/', include('openedx.core.djangoapps.bookmarks.urls')),
# Profile Images API endpoints
url(r'^api/profile_images/', include('openedx.core.djangoapps.profile_images.urls')),
......
"""
Bookmarks module.
"""
from collections import namedtuple
DEFAULT_FIELDS = [
'id',
'course_id',
'usage_id',
'block_type',
'created',
]
OPTIONAL_FIELDS = [
'display_name',
'path',
]
PathItem = namedtuple('PathItem', ['usage_key', 'display_name'])
"""
Bookmarks Python API.
"""
from eventtracking import tracker
from . import DEFAULT_FIELDS, OPTIONAL_FIELDS
from xmodule.modulestore.django import modulestore
from django.conf import settings
from xmodule.modulestore.exceptions import ItemNotFoundError
from .models import Bookmark
from .serializers import BookmarkSerializer
class BookmarksLimitReachedError(Exception):
"""
if try to create new bookmark when max limit of bookmarks already reached
"""
pass
def get_bookmark(user, usage_key, fields=None):
"""
Return data for a bookmark.
Arguments:
user (User): The user of the bookmark.
usage_key (UsageKey): The usage_key of the bookmark.
fields (list): List of field names the data should contain (optional).
Returns:
Dict.
Raises:
ObjectDoesNotExist: If a bookmark with the parameters does not exist.
"""
bookmarks_queryset = Bookmark.objects
if len(set(fields or []) & set(OPTIONAL_FIELDS)) > 0:
bookmarks_queryset = bookmarks_queryset.select_related('user', 'xblock_cache')
else:
bookmarks_queryset = bookmarks_queryset.select_related('user')
bookmark = bookmarks_queryset.get(user=user, usage_key=usage_key)
return BookmarkSerializer(bookmark, context={'fields': fields}).data
def get_bookmarks(user, course_key=None, fields=None, serialized=True):
"""
Return data for bookmarks of a user.
Arguments:
user (User): The user of the bookmarks.
course_key (CourseKey): The course_key of the bookmarks (optional).
fields (list): List of field names the data should contain (optional).
N/A if serialized is False.
serialized (bool): Whether to return a queryset or a serialized list of dicts.
Default is True.
Returns:
List of dicts if serialized is True else queryset.
"""
bookmarks_queryset = Bookmark.objects.filter(user=user)
if course_key:
bookmarks_queryset = bookmarks_queryset.filter(course_key=course_key)
if len(set(fields or []) & set(OPTIONAL_FIELDS)) > 0:
bookmarks_queryset = bookmarks_queryset.select_related('user', 'xblock_cache')
else:
bookmarks_queryset = bookmarks_queryset.select_related('user')
bookmarks_queryset = bookmarks_queryset.order_by('-created')
if serialized:
return BookmarkSerializer(bookmarks_queryset, context={'fields': fields}, many=True).data
return bookmarks_queryset
def can_create_more(data):
"""
Determine if a new Bookmark can be created for the course
based on limit defined in django.conf.settings.MAX_BOOKMARKS_PER_COURSE
Arguments:
data (dict): The data to create the object with.
Returns:
Boolean
"""
data = dict(data)
user = data['user']
course_key = data['usage_key'].course_key
# User can create up to max_bookmarks_per_course bookmarks
if Bookmark.objects.filter(user=user, course_key=course_key).count() >= settings.MAX_BOOKMARKS_PER_COURSE:
return False
return True
def create_bookmark(user, usage_key):
"""
Create a bookmark.
Arguments:
user (User): The user of the bookmark.
usage_key (UsageKey): The usage_key of the bookmark.
Returns:
Dict.
Raises:
ItemNotFoundError: If no block exists for the usage_key.
BookmarksLimitReachedError: if try to create new bookmark when max limit of bookmarks already reached
"""
usage_key = usage_key.replace(course_key=modulestore().fill_in_run(usage_key.course_key))
data = {
'user': user,
'usage_key': usage_key
}
if usage_key.course_key.run is None:
raise ItemNotFoundError
if not can_create_more(data):
raise BookmarksLimitReachedError
bookmark, created = Bookmark.create(data)
if created:
_track_event('edx.bookmark.added', bookmark)
return BookmarkSerializer(bookmark, context={'fields': DEFAULT_FIELDS + OPTIONAL_FIELDS}).data
def delete_bookmark(user, usage_key):
"""
Delete a bookmark.
Arguments:
user (User): The user of the bookmark.
usage_key (UsageKey): The usage_key of the bookmark.
Returns:
Dict.
Raises:
ObjectDoesNotExist: If a bookmark with the parameters does not exist.
"""
bookmark = Bookmark.objects.get(user=user, usage_key=usage_key)
bookmark.delete()
_track_event('edx.bookmark.removed', bookmark)
def _track_event(event_name, bookmark):
"""
Emit events for a bookmark.
Arguments:
event_name: name of event to track
bookmark: Bookmark object
"""
tracker.emit(
event_name,
{
'course_id': unicode(bookmark.course_key),
'bookmark_id': bookmark.resource_id,
'component_type': bookmark.usage_key.block_type,
'component_usage_id': unicode(bookmark.usage_key),
}
)
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
import model_utils.fields
import xmodule_django.models
import jsonfield.fields
import django.utils.timezone
from django.conf import settings
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Bookmark',
fields=[
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, verbose_name='created', editable=False)),
('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, verbose_name='modified', editable=False)),
('course_key', xmodule_django.models.CourseKeyField(max_length=255, db_index=True)),
('usage_key', xmodule_django.models.LocationKeyField(max_length=255, db_index=True)),
('_path', jsonfield.fields.JSONField(help_text=b'Path in course tree to the block', db_column=b'path')),
('user', models.ForeignKey(to=settings.AUTH_USER_MODEL)),
],
),
migrations.CreateModel(
name='XBlockCache',
fields=[
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, verbose_name='created', editable=False)),
('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, verbose_name='modified', editable=False)),
('course_key', xmodule_django.models.CourseKeyField(max_length=255, db_index=True)),
('usage_key', xmodule_django.models.LocationKeyField(unique=True, max_length=255, db_index=True)),
('display_name', models.CharField(default=b'', max_length=255)),
('_paths', jsonfield.fields.JSONField(default=[], help_text=b'All paths in course tree to the corresponding block.', db_column=b'paths')),
],
options={
'abstract': False,
},
),
migrations.AddField(
model_name='bookmark',
name='xblock_cache',
field=models.ForeignKey(to='bookmarks.XBlockCache'),
),
migrations.AlterUniqueTogether(
name='bookmark',
unique_together=set([('user', 'usage_key')]),
),
]
"""
Models for Bookmarks.
"""
import logging
from django.contrib.auth.models import User
from django.db import models
from jsonfield.fields import JSONField
from model_utils.models import TimeStampedModel
from opaque_keys.edx.keys import UsageKey
from xmodule.modulestore import search
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.exceptions import ItemNotFoundError, NoPathToItem
from xmodule_django.models import CourseKeyField, LocationKeyField
from . import PathItem
log = logging.getLogger(__name__)
def prepare_path_for_serialization(path):
"""
Return the data from a list of PathItems ready for serialization to json.
"""
return [(unicode(path_item.usage_key), path_item.display_name) for path_item in path]
def parse_path_data(path_data):
"""
Return a list of PathItems constructed from parsing path_data.
"""
path = []
for item in path_data:
usage_key = UsageKey.from_string(item[0])
usage_key = usage_key.replace(course_key=modulestore().fill_in_run(usage_key.course_key))
path.append(PathItem(usage_key, item[1]))
return path
class Bookmark(TimeStampedModel):
"""
Bookmarks model.
"""
user = models.ForeignKey(User, db_index=True)
course_key = CourseKeyField(max_length=255, db_index=True)
usage_key = LocationKeyField(max_length=255, db_index=True)
_path = JSONField(db_column='path', help_text='Path in course tree to the block')
xblock_cache = models.ForeignKey('bookmarks.XBlockCache')
class Meta(object):
"""
Bookmark metadata.
"""
unique_together = ('user', 'usage_key')
def __unicode__(self):
return self.resource_id
@classmethod
def create(cls, data):
"""
Create a Bookmark object.
Arguments:
data (dict): The data to create the object with.
Returns:
A Bookmark object.
Raises:
ItemNotFoundError: If no block exists for the usage_key.
"""
data = dict(data)
usage_key = data.pop('usage_key')
with modulestore().bulk_operations(usage_key.course_key):
block = modulestore().get_item(usage_key)
xblock_cache = XBlockCache.create({
'usage_key': usage_key,
'display_name': block.display_name,
})
data['_path'] = prepare_path_for_serialization(Bookmark.updated_path(usage_key, xblock_cache))
data['course_key'] = usage_key.course_key
data['xblock_cache'] = xblock_cache
user = data.pop('user')
bookmark, created = cls.objects.get_or_create(usage_key=usage_key, user=user, defaults=data)
return bookmark, created
@property
def resource_id(self):
"""
Return the resource id: {username,usage_id}.
"""
return "{0},{1}".format(self.user.username, self.usage_key) # pylint: disable=no-member
@property
def display_name(self):
"""
Return the display_name from self.xblock_cache.
Returns:
String.
"""
return self.xblock_cache.display_name # pylint: disable=no-member
@property
def path(self):
"""
Return the path to the bookmark's block after checking self.xblock_cache.
Returns:
List of dicts.
"""
if self.modified < self.xblock_cache.modified: # pylint: disable=no-member
path = Bookmark.updated_path(self.usage_key, self.xblock_cache)
self._path = prepare_path_for_serialization(path)
self.save() # Always save so that self.modified is updated.
return path
return parse_path_data(self._path)
@staticmethod
def updated_path(usage_key, xblock_cache):
"""
Return the update-to-date path.
xblock_cache.paths is the list of all possible paths to a block
constructed by doing a DFS of the tree. However, in case of DAGS,
which section jump_to_id() takes the user to depends on the
modulestore. If xblock_cache.paths has only one item, we can
just use it. Otherwise, we use path_to_location() to get the path
jump_to_id() will take the user to.
"""
if xblock_cache.paths and len(xblock_cache.paths) == 1:
return xblock_cache.paths[0]
return Bookmark.get_path(usage_key)
@staticmethod
def get_path(usage_key):
"""
Returns data for the path to the block in the course graph.
Note: In case of multiple paths to the block from the course
root, this function returns a path arbitrarily but consistently,
depending on the modulestore. In the future, we may want to
extend it to check which of the paths, the user has access to
and return its data.
Arguments:
block (XBlock): The block whose path is required.
Returns:
list of PathItems
"""
with modulestore().bulk_operations(usage_key.course_key):
try:
path = search.path_to_location(modulestore(), usage_key, full_path=True)
except ItemNotFoundError:
log.error(u'Block with usage_key: %s not found.', usage_key)
return []
except NoPathToItem:
log.error(u'No path to block with usage_key: %s.', usage_key)
return []
path_data = []
for ancestor_usage_key in path:
if ancestor_usage_key != usage_key and ancestor_usage_key.block_type != 'course': # pylint: disable=no-member
try:
block = modulestore().get_item(ancestor_usage_key)
except ItemNotFoundError:
return [] # No valid path can be found.
path_data.append(
PathItem(usage_key=block.location, display_name=block.display_name)
)
return path_data
class XBlockCache(TimeStampedModel):
"""
XBlockCache model to store info about xblocks.
"""
course_key = CourseKeyField(max_length=255, db_index=True)
usage_key = LocationKeyField(max_length=255, db_index=True, unique=True)
display_name = models.CharField(max_length=255, default='')
_paths = JSONField(
db_column='paths', default=[], help_text='All paths in course tree to the corresponding block.'
)
def __unicode__(self):
return unicode(self.usage_key)
@property
def paths(self):
"""
Return paths.
Returns:
list of list of PathItems.
"""
return [parse_path_data(path) for path in self._paths] if self._paths else self._paths
@paths.setter
def paths(self, value):
"""
Set paths.
Arguments:
value (list of list of PathItems): The list of paths to cache.
"""
self._paths = [prepare_path_for_serialization(path) for path in value] if value else value
@classmethod
def create(cls, data):
"""
Create an XBlockCache object.
Arguments:
data (dict): The data to create the object with.
Returns:
An XBlockCache object.
"""
data = dict(data)
usage_key = data.pop('usage_key')
usage_key = usage_key.replace(course_key=modulestore().fill_in_run(usage_key.course_key))
data['course_key'] = usage_key.course_key
xblock_cache, created = cls.objects.get_or_create(usage_key=usage_key, defaults=data)
if not created:
new_display_name = data.get('display_name', xblock_cache.display_name)
if xblock_cache.display_name != new_display_name:
xblock_cache.display_name = new_display_name
xblock_cache.save()
return xblock_cache
"""
Serializers for Bookmarks.
"""
from rest_framework import serializers
from openedx.core.lib.api.serializers import CourseKeyField, UsageKeyField
from . import DEFAULT_FIELDS
from .models import Bookmark
class BookmarkSerializer(serializers.ModelSerializer):
"""
Serializer for the Bookmark model.
"""
id = serializers.SerializerMethodField() # pylint: disable=invalid-name
course_id = CourseKeyField(source='course_key')
usage_id = UsageKeyField(source='usage_key')
block_type = serializers.ReadOnlyField(source='usage_key.block_type')
display_name = serializers.ReadOnlyField()
path = serializers.SerializerMethodField()
def __init__(self, *args, **kwargs):
# Don't pass the 'fields' arg up to the superclass
try:
fields = kwargs['context'].pop('fields', DEFAULT_FIELDS) or DEFAULT_FIELDS
except KeyError:
fields = DEFAULT_FIELDS
# Instantiate the superclass normally
super(BookmarkSerializer, self).__init__(*args, **kwargs)
# Drop any fields that are not specified in the `fields` argument.
required_fields = set(fields)
all_fields = set(self.fields.keys())
for field_name in all_fields - required_fields:
self.fields.pop(field_name)
class Meta(object):
""" Serializer metadata. """
model = Bookmark
fields = (
'id',
'course_id',
'usage_id',
'block_type',
'display_name',
'path',
'created',
)
def get_id(self, bookmark):
"""
Return the REST resource id: {username,usage_id}.
"""
return "{0},{1}".format(bookmark.user.username, bookmark.usage_key)
def get_path(self, bookmark):
"""
Serialize and return the path data of the bookmark.
"""
path_items = [path_item._asdict() for path_item in bookmark.path]
for path_item in path_items:
path_item['usage_key'] = unicode(path_item['usage_key'])
return path_items
"""
Bookmarks service.
"""
import logging
from django.core.exceptions import ObjectDoesNotExist
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.exceptions import ItemNotFoundError
from request_cache.middleware import RequestCache
from . import DEFAULT_FIELDS, api
log = logging.getLogger(__name__)
CACHE_KEY_TEMPLATE = u"bookmarks.list.{}.{}"
class BookmarksService(object):
"""
A service that provides access to the bookmarks API.
When bookmarks() or is_bookmarked() is called for the
first time, the service fetches and caches all the bookmarks
of the user for the relevant course. So multiple calls to
get bookmark status during a request (for, example when
rendering courseware and getting bookmarks status for search
results) will not cause repeated queries to the database.
"""
def __init__(self, user, **kwargs):
super(BookmarksService, self).__init__(**kwargs)
self._user = user
def _bookmarks_cache(self, course_key, fetch=False):
"""
Return the user's bookmarks cache for a particular course.
Arguments:
course_key (CourseKey): course_key of the course whose bookmarks cache should be returned.
fetch (Bool): if the bookmarks should be fetched and cached if they already aren't.
"""
store = modulestore()
course_key = store.fill_in_run(course_key)
if course_key.run is None:
return []
cache_key = CACHE_KEY_TEMPLATE.format(self._user.id, course_key)
bookmarks_cache = RequestCache.get_request_cache().data.get(cache_key, None)
if bookmarks_cache is None and fetch is True:
bookmarks_cache = api.get_bookmarks(
self._user, course_key=course_key, fields=DEFAULT_FIELDS
)
RequestCache.get_request_cache().data[cache_key] = bookmarks_cache
return bookmarks_cache
def bookmarks(self, course_key):
"""
Return a list of bookmarks for the course for the current user.
Arguments:
course_key: CourseKey of the course for which to retrieve the user's bookmarks for.
Returns:
list of dict:
"""
return self._bookmarks_cache(course_key, fetch=True)
def is_bookmarked(self, usage_key):
"""
Return whether the block has been bookmarked by the user.
Arguments:
usage_key: UsageKey of the block.
Returns:
Bool
"""
usage_id = unicode(usage_key)
bookmarks_cache = self._bookmarks_cache(usage_key.course_key, fetch=True)
for bookmark in bookmarks_cache:
if bookmark['usage_id'] == usage_id:
return True
return False
def set_bookmarked(self, usage_key):
"""
Adds a bookmark for the block.
Arguments:
usage_key: UsageKey of the block.
Returns:
Bool indicating whether the bookmark was added.
"""
try:
bookmark = api.create_bookmark(user=self._user, usage_key=usage_key)
except ItemNotFoundError:
log.error(u'Block with usage_id: %s not found.', usage_key)
return False
bookmarks_cache = self._bookmarks_cache(usage_key.course_key)
if bookmarks_cache is not None:
bookmarks_cache.append(bookmark)
return True
def unset_bookmarked(self, usage_key):
"""
Removes the bookmark for the block.
Arguments:
usage_key: UsageKey of the block.
Returns:
Bool indicating whether the bookmark was removed.
"""
try:
api.delete_bookmark(self._user, usage_key=usage_key)
except ObjectDoesNotExist:
log.error(u'Bookmark with usage_id: %s does not exist.', usage_key)
return False
bookmarks_cache = self._bookmarks_cache(usage_key.course_key)
if bookmarks_cache is not None:
deleted_bookmark_index = None
usage_id = unicode(usage_key)
for index, bookmark in enumerate(bookmarks_cache):
if bookmark['usage_id'] == usage_id:
deleted_bookmark_index = index
break
if deleted_bookmark_index is not None:
bookmarks_cache.pop(deleted_bookmark_index)
return True
"""
Signals for bookmarks.
"""
from importlib import import_module
from django.dispatch.dispatcher import receiver
from xmodule.modulestore.django import SignalHandler
@receiver(SignalHandler.course_published)
def trigger_update_xblocks_cache_task(sender, course_key, **kwargs): # pylint: disable=invalid-name,unused-argument
"""
Trigger update_xblocks_cache() when course_published signal is fired.
"""
tasks = import_module('openedx.core.djangoapps.bookmarks.tasks') # Importing tasks early causes issues in tests.
# Note: The countdown=0 kwarg is set to ensure the method below does not attempt to access the course
# before the signal emitter has finished all operations. This is also necessary to ensure all tests pass.
tasks.update_xblocks_cache.apply_async([unicode(course_key)], countdown=0)
"""
Setup the signals on startup.
"""
from . import signals # pylint: disable=unused-import
"""
Tasks for bookmarks.
"""
import logging
from django.db import transaction
from celery.task import task # pylint: disable=import-error,no-name-in-module
from opaque_keys.edx.keys import CourseKey
from xmodule.modulestore.django import modulestore
from . import PathItem
log = logging.getLogger('edx.celery.task')
def _calculate_course_xblocks_data(course_key):
"""
Fetch data for all the blocks in the course.
This data consists of the display_name and path of the block.
"""
with modulestore().bulk_operations(course_key):
course = modulestore().get_course(course_key, depth=None)
blocks_info_dict = {}
# Collect display_name and children usage keys.
blocks_stack = [course]
while blocks_stack:
current_block = blocks_stack.pop()
children = current_block.get_children() if current_block.has_children else []
usage_id = unicode(current_block.scope_ids.usage_id)
block_info = {
'usage_key': current_block.scope_ids.usage_id,
'display_name': current_block.display_name,
'children_ids': [unicode(child.scope_ids.usage_id) for child in children]
}
blocks_info_dict[usage_id] = block_info
# Add this blocks children to the stack so that we can traverse them as well.
blocks_stack.extend(children)
# Set children
for block in blocks_info_dict.values():
block.setdefault('children', [])
for child_id in block['children_ids']:
block['children'].append(blocks_info_dict[child_id])
block.pop('children_ids', None)
# Calculate paths
def add_path_info(block_info, current_path):
"""Do a DFS and add paths info to each block_info."""
block_info.setdefault('paths', [])
block_info['paths'].append(current_path)
for child_block_info in block_info['children']:
add_path_info(child_block_info, current_path + [block_info])
add_path_info(blocks_info_dict[unicode(course.scope_ids.usage_id)], [])
return blocks_info_dict
def _paths_from_data(paths_data):
"""
Construct a list of paths from path data.
"""
paths = []
for path_data in paths_data:
paths.append([
PathItem(item['usage_key'], item['display_name']) for item in path_data
if item['usage_key'].block_type != 'course'
])
return [path for path in paths if path]
def paths_equal(paths_1, paths_2):
"""
Check if two paths are equivalent.
"""
if len(paths_1) != len(paths_2):
return False
for path_1, path_2 in zip(paths_1, paths_2):
if len(path_1) != len(path_2):
return False
for path_item_1, path_item_2 in zip(path_1, path_2):
if path_item_1.display_name != path_item_2.display_name:
return False
usage_key_1 = path_item_1.usage_key.replace(
course_key=modulestore().fill_in_run(path_item_1.usage_key.course_key)
)
usage_key_2 = path_item_1.usage_key.replace(
course_key=modulestore().fill_in_run(path_item_2.usage_key.course_key)
)
if usage_key_1 != usage_key_2:
return False
return True
def _update_xblocks_cache(course_key):
"""
Calculate the XBlock cache data for a course and update the XBlockCache table.
"""
from .models import XBlockCache
blocks_data = _calculate_course_xblocks_data(course_key)
def update_block_cache_if_needed(block_cache, block_data):
""" Compare block_cache object with data and update if there are differences. """
paths = _paths_from_data(block_data['paths'])
if block_cache.display_name != block_data['display_name'] or not paths_equal(block_cache.paths, paths):
log.info(u'Updating XBlockCache with usage_key: %s', unicode(block_cache.usage_key))
block_cache.display_name = block_data['display_name']
block_cache.paths = paths
block_cache.save()
with transaction.atomic():
block_caches = XBlockCache.objects.filter(course_key=course_key)
for block_cache in block_caches:
block_data = blocks_data.pop(unicode(block_cache.usage_key), None)
if block_data:
update_block_cache_if_needed(block_cache, block_data)
for block_data in blocks_data.values():
with transaction.atomic():
paths = _paths_from_data(block_data['paths'])
log.info(u'Creating XBlockCache with usage_key: %s', unicode(block_data['usage_key']))
block_cache, created = XBlockCache.objects.get_or_create(usage_key=block_data['usage_key'], defaults={
'course_key': course_key,
'display_name': block_data['display_name'],
'paths': paths,
})
if not created:
update_block_cache_if_needed(block_cache, block_data)
@task(name=u'openedx.core.djangoapps.bookmarks.tasks.update_xblock_cache')
def update_xblocks_cache(course_id):
"""
Update the XBlocks cache for a course.
Arguments:
course_id (String): The course_id of a course.
"""
# Ideally we'd like to accept a CourseLocator; however, CourseLocator is not JSON-serializable (by default) so
# Celery's delayed tasks fail to start. For this reason, callers should pass the course key as a Unicode string.
if not isinstance(course_id, basestring):
raise ValueError('course_id must be a string. {} is not acceptable.'.format(type(course_id)))
course_key = CourseKey.from_string(course_id)
log.info(u'Starting XBlockCaches update for course_key: %s', course_id)
_update_xblocks_cache(course_key)
log.info(u'Ending XBlockCaches update for course_key: %s', course_id)
"""
Factories for Bookmark models.
"""
import factory
from factory.django import DjangoModelFactory
from functools import partial
from student.tests.factories import UserFactory
from opaque_keys.edx.locations import SlashSeparatedCourseKey
from ..models import Bookmark, XBlockCache
COURSE_KEY = SlashSeparatedCourseKey(u'edX', u'test_course', u'test')
LOCATION = partial(COURSE_KEY.make_usage_key, u'problem')
class BookmarkFactory(DjangoModelFactory):
""" Simple factory class for generating Bookmark """
class Meta(object):
model = Bookmark
user = factory.SubFactory(UserFactory)
course_key = COURSE_KEY
usage_key = LOCATION('usage_id')
path = list()
xblock_cache = factory.SubFactory(
'openedx.core.djangoapps.bookmarks.tests.factories.XBlockCacheFactory',
course_key=factory.SelfAttribute('..course_key'),
usage_key=factory.SelfAttribute('..usage_key'),
)
class XBlockCacheFactory(DjangoModelFactory):
""" Simple factory class for generating XblockCache. """
class Meta(object):
model = XBlockCache
course_key = COURSE_KEY
usage_key = factory.Sequence(u'4x://edx/100/block/{0}'.format)
display_name = ''
paths = list()
"""
Tests for bookmark services.
"""
from unittest import skipUnless
from django.conf import settings
from opaque_keys.edx.keys import UsageKey
from ..services import BookmarksService
from .test_models import BookmarksTestsBase
@skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Tests only valid in LMS')
class BookmarksServiceTests(BookmarksTestsBase):
"""
Tests the Bookmarks service.
"""
def setUp(self):
super(BookmarksServiceTests, self).setUp()
self.bookmark_service = BookmarksService(user=self.user)
def test_get_bookmarks(self):
"""
Verifies get_bookmarks returns data as expected.
"""
with self.assertNumQueries(1):
bookmarks_data = self.bookmark_service.bookmarks(course_key=self.course.id)
self.assertEqual(len(bookmarks_data), 2)
self.assert_bookmark_data_is_valid(self.bookmark_2, bookmarks_data[0])
self.assert_bookmark_data_is_valid(self.bookmark_1, bookmarks_data[1])
def test_is_bookmarked(self):
"""
Verifies is_bookmarked returns Bool as expected.
"""
with self.assertNumQueries(1):
self.assertTrue(self.bookmark_service.is_bookmarked(usage_key=self.sequential_1.location))
self.assertFalse(self.bookmark_service.is_bookmarked(usage_key=self.vertical_2.location))
self.assertTrue(self.bookmark_service.is_bookmarked(usage_key=self.sequential_2.location))
self.bookmark_service.set_bookmarked(usage_key=self.chapter_1.location)
with self.assertNumQueries(0):
self.assertTrue(self.bookmark_service.is_bookmarked(usage_key=self.chapter_1.location))
self.assertFalse(self.bookmark_service.is_bookmarked(usage_key=self.vertical_2.location))
# Removing a bookmark should result in the cache being updated on the next request
self.bookmark_service.unset_bookmarked(usage_key=self.chapter_1.location)
with self.assertNumQueries(0):
self.assertFalse(self.bookmark_service.is_bookmarked(usage_key=self.chapter_1.location))
self.assertFalse(self.bookmark_service.is_bookmarked(usage_key=self.vertical_2.location))
# Get bookmark that does not exist.
bookmark_service = BookmarksService(self.other_user)
with self.assertNumQueries(1):
self.assertFalse(bookmark_service.is_bookmarked(usage_key=self.sequential_1.location))
def test_set_bookmarked(self):
"""
Verifies set_bookmarked returns Bool as expected.
"""
# Assert False for item that does not exist.
with self.assertNumQueries(0):
self.assertFalse(
self.bookmark_service.set_bookmarked(usage_key=UsageKey.from_string("i4x://ed/ed/ed/interactive"))
)
with self.assertNumQueries(9):
self.assertTrue(self.bookmark_service.set_bookmarked(usage_key=self.vertical_2.location))
def test_unset_bookmarked(self):
"""
Verifies unset_bookmarked returns Bool as expected.
"""
with self.assertNumQueries(1):
self.assertFalse(
self.bookmark_service.unset_bookmarked(usage_key=UsageKey.from_string("i4x://ed/ed/ed/interactive"))
)
with self.assertNumQueries(3):
self.assertTrue(self.bookmark_service.unset_bookmarked(usage_key=self.sequential_1.location))
"""
Tests for tasks.
"""
import ddt
from xmodule.modulestore import ModuleStoreEnum
from xmodule.modulestore.tests.factories import check_mongo_calls
from ..models import XBlockCache
from ..tasks import _calculate_course_xblocks_data, _update_xblocks_cache
from .test_models import BookmarksTestsBase
@ddt.ddt
class XBlockCacheTaskTests(BookmarksTestsBase):
"""
Test the XBlockCache model.
"""
def setUp(self):
super(XBlockCacheTaskTests, self).setUp()
self.course_expected_cache_data = {
self.course.location: [
[],
], self.chapter_1.location: [
[
self.course.location,
],
], self.chapter_2.location: [
[
self.course.location,
],
], self.sequential_1.location: [
[
self.course.location,
self.chapter_1.location,
],
], self.sequential_2.location: [
[
self.course.location,
self.chapter_1.location,
],
], self.vertical_1.location: [
[
self.course.location,
self.chapter_1.location,
self.sequential_1.location,
],
], self.vertical_2.location: [
[
self.course.location,
self.chapter_1.location,
self.sequential_2.location,
],
], self.vertical_3.location: [
[
self.course.location,
self.chapter_1.location,
self.sequential_2.location,
],
],
}
self.other_course_expected_cache_data = { # pylint: disable=invalid-name
self.other_course.location: [
[],
], self.other_chapter_1.location: [
[
self.other_course.location,
],
], self.other_sequential_1.location: [
[
self.other_course.location,
self.other_chapter_1.location,
],
], self.other_sequential_2.location: [
[
self.other_course.location,
self.other_chapter_1.location,
],
], self.other_vertical_1.location: [
[
self.other_course.location,
self.other_chapter_1.location,
self.other_sequential_1.location,
],
[
self.other_course.location,
self.other_chapter_1.location,
self.other_sequential_2.location,
]
], self.other_vertical_2.location: [
[
self.other_course.location,
self.other_chapter_1.location,
self.other_sequential_1.location,
],
],
}
@ddt.data(
(ModuleStoreEnum.Type.mongo, 2, 2, 3),
(ModuleStoreEnum.Type.mongo, 4, 2, 3),
(ModuleStoreEnum.Type.mongo, 2, 3, 4),
(ModuleStoreEnum.Type.mongo, 4, 3, 4),
(ModuleStoreEnum.Type.mongo, 2, 4, 5),
# (ModuleStoreEnum.Type.mongo, 4, 4, 6), Too slow.
(ModuleStoreEnum.Type.split, 2, 2, 3),
(ModuleStoreEnum.Type.split, 4, 2, 3),
(ModuleStoreEnum.Type.split, 2, 3, 3),
(ModuleStoreEnum.Type.split, 2, 4, 3),
)
@ddt.unpack
def test_calculate_course_xblocks_data_queries(self, store_type, children_per_block, depth, expected_mongo_calls):
course = self.create_course_with_blocks(children_per_block, depth, store_type)
with check_mongo_calls(expected_mongo_calls):
blocks_data = _calculate_course_xblocks_data(course.id)
self.assertGreater(len(blocks_data), children_per_block ** depth)
@ddt.data(
('course',),
('other_course',)
)
@ddt.unpack
def test_calculate_course_xblocks_data(self, course_attr):
"""
Test that the xblocks data is calculated correctly.
"""
course = getattr(self, course_attr)
blocks_data = _calculate_course_xblocks_data(course.id)
expected_cache_data = getattr(self, course_attr + '_expected_cache_data')
for usage_key, __ in expected_cache_data.items():
for path_index, path in enumerate(blocks_data[unicode(usage_key)]['paths']):
for path_item_index, path_item in enumerate(path):
self.assertEqual(
path_item['usage_key'], expected_cache_data[usage_key][path_index][path_item_index]
)
@ddt.data(
('course', 47),
('other_course', 34)
)
@ddt.unpack
def test_update_xblocks_cache(self, course_attr, expected_sql_queries):
"""
Test that the xblocks data is persisted correctly.
"""
course = getattr(self, course_attr)
with self.assertNumQueries(expected_sql_queries):
_update_xblocks_cache(course.id)
expected_cache_data = getattr(self, course_attr + '_expected_cache_data')
for usage_key, __ in expected_cache_data.items():
xblock_cache = XBlockCache.objects.get(usage_key=usage_key)
for path_index, path in enumerate(xblock_cache.paths):
for path_item_index, path_item in enumerate(path):
self.assertEqual(
path_item.usage_key, expected_cache_data[usage_key][path_index][path_item_index + 1]
)
with self.assertNumQueries(3):
_update_xblocks_cache(course.id)
"""
URL routes for the bookmarks app.
"""
from django.conf import settings
from django.conf.urls import patterns, url
from .views import BookmarksListView, BookmarksDetailView
urlpatterns = patterns(
'bookmarks',
url(
r'^v1/bookmarks/$',
BookmarksListView.as_view(),
name='bookmarks'
),
url(
r'^v1/bookmarks/{username},{usage_key}/$'.format(
username=settings.USERNAME_PATTERN,
usage_key=settings.USAGE_ID_PATTERN
),
BookmarksDetailView.as_view(),
name='bookmarks_detail'
),
)
......@@ -6,33 +6,18 @@ import logging
from django.conf import settings
from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import CourseKey
import pytz
from rest_framework import serializers
from rest_framework.exceptions import PermissionDenied
from openedx.core.djangoapps.credit.models import CreditCourse, CreditProvider, CreditEligibility, CreditRequest
from openedx.core.djangoapps.credit.signature import get_shared_secret_key, signature
from openedx.core.lib.api.serializers import CourseKeyField
from util.date_utils import from_timestamp
log = logging.getLogger(__name__)
class CourseKeyField(serializers.Field):
""" Serializer field for a model CourseKey field. """
def to_representation(self, data):
"""Convert a course key to unicode. """
return unicode(data)
def to_internal_value(self, data):
"""Convert unicode to a course key. """
try:
return CourseKey.from_string(data)
except InvalidKeyError as ex:
raise serializers.ValidationError("Invalid course key: {msg}".format(msg=ex.msg))
class CreditCourseSerializer(serializers.ModelSerializer):
""" CreditCourse Serializer """
......
......@@ -9,18 +9,18 @@ NOTE: These views are deprecated. These routes are superseded by
from django.conf.urls import patterns, url
from .views import ProfileImageUploadView, ProfileImageRemoveView
from django.conf import settings
USERNAME_PATTERN = r'(?P<username>[\w.+-]+)'
urlpatterns = patterns(
'',
url(
r'^v1/' + USERNAME_PATTERN + '/upload$',
r'^v1/' + settings.USERNAME_PATTERN + '/upload$',
ProfileImageUploadView.as_view(),
name="profile_image_upload"
),
url(
r'^v1/' + USERNAME_PATTERN + '/remove$',
r'^v1/' + settings.USERNAME_PATTERN + '/remove$',
ProfileImageRemoveView.as_view(),
name="profile_image_remove"
),
......
......@@ -2,33 +2,32 @@
Defines the URL routes for this app.
"""
from django.conf import settings
from django.conf.urls import patterns, url
from ..profile_images.views import ProfileImageView
from .accounts.views import AccountView
from .preferences.views import PreferencesView, PreferencesDetailView
USERNAME_PATTERN = r'(?P<username>[\w.+-]+)'
urlpatterns = patterns(
'',
url(
r'^v1/accounts/{}$'.format(USERNAME_PATTERN),
r'^v1/accounts/{}$'.format(settings.USERNAME_PATTERN),
AccountView.as_view(),
name="accounts_api"
),
url(
r'^v1/accounts/{}/image$'.format(USERNAME_PATTERN),
r'^v1/accounts/{}/image$'.format(settings.USERNAME_PATTERN),
ProfileImageView.as_view(),
name="accounts_profile_image_api"
),
url(
r'^v1/preferences/{}$'.format(USERNAME_PATTERN),
r'^v1/preferences/{}$'.format(settings.USERNAME_PATTERN),
PreferencesView.as_view(),
name="preferences_api"
),
url(
r'^v1/preferences/{}/(?P<preference_key>[a-zA-Z0-9_]+)$'.format(USERNAME_PATTERN),
r'^v1/preferences/{}/(?P<preference_key>[a-zA-Z0-9_]+)$'.format(settings.USERNAME_PATTERN),
PreferencesDetailView.as_view(),
name="preferences_detail_api"
),
......
......@@ -3,6 +3,8 @@ Serializers to be used in APIs.
"""
from rest_framework import serializers
from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import CourseKey, UsageKey
class CollapsedReferenceSerializer(serializers.HyperlinkedModelSerializer):
......@@ -37,3 +39,33 @@ class CollapsedReferenceSerializer(serializers.HyperlinkedModelSerializer):
class Meta(object):
fields = ("url",)
class CourseKeyField(serializers.Field):
""" Serializer field for a model CourseKey field. """
def to_representation(self, data):
"""Convert a course key to unicode. """
return unicode(data)
def to_internal_value(self, data):
"""Convert unicode to a course key. """
try:
return CourseKey.from_string(data)
except InvalidKeyError as ex:
raise serializers.ValidationError("Invalid course key: {msg}".format(msg=ex.msg))
class UsageKeyField(serializers.Field):
""" Serializer field for a model UsageKey field. """
def to_representation(self, data):
"""Convert a usage key to unicode. """
return unicode(data)
def to_internal_value(self, data):
"""Convert unicode to a usage key. """
try:
return UsageKey.from_string(data)
except InvalidKeyError as ex:
raise serializers.ValidationError("Invalid usage key: {msg}".format(msg=ex.msg))
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment