@@ -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'
|
||||
),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
$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):
|
||||
"""
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
/* 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):
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<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)
|
||||
|
||||
65
common/test/acceptance/pages/lms/bookmarks.py
Normal file
65
common/test/acceptance/pages/lms/bookmarks.py
Normal file
@@ -0,0 +1,65 @@
|
||||
"""
|
||||
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
|
||||
|
||||
640
common/test/acceptance/tests/lms/test_bookmarks.py
Normal file
640
common/test/acceptance/tests/lms/test_bookmarks.py
Normal file
@@ -0,0 +1,640 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
End-to-end tests for the courseware unit bookmarks.
|
||||
"""
|
||||
import json
|
||||
import requests
|
||||
from ...pages.studio.auto_auth import AutoAuthPage as StudioAutoAuthPage
|
||||
from ...pages.lms.auto_auth import AutoAuthPage as LmsAutoAuthPage
|
||||
from ...pages.lms.bookmarks import BookmarksPage
|
||||
from ...pages.lms.courseware import CoursewarePage
|
||||
from ...pages.lms.course_nav import CourseNavPage
|
||||
from ...pages.studio.overview import CourseOutlinePage
|
||||
from ...pages.common.logout import LogoutPage
|
||||
from ...pages.common import BASE_URL
|
||||
|
||||
from ...fixtures.course import CourseFixture, XBlockFixtureDesc
|
||||
from ..helpers import EventsTestMixin, UniqueCourseTest, is_404_page
|
||||
|
||||
|
||||
class BookmarksTestMixin(EventsTestMixin, UniqueCourseTest):
|
||||
"""
|
||||
Mixin with helper methods for testing Bookmarks.
|
||||
"""
|
||||
USERNAME = "STUDENT"
|
||||
EMAIL = "student@example.com"
|
||||
|
||||
def create_course_fixture(self, num_chapters):
|
||||
"""
|
||||
Create course fixture
|
||||
|
||||
Arguments:
|
||||
num_chapters: number of chapters to create
|
||||
"""
|
||||
self.course_fixture = CourseFixture( # pylint: disable=attribute-defined-outside-init
|
||||
self.course_info['org'], self.course_info['number'],
|
||||
self.course_info['run'], self.course_info['display_name']
|
||||
)
|
||||
|
||||
xblocks = []
|
||||
for index in range(num_chapters):
|
||||
xblocks += [
|
||||
XBlockFixtureDesc('chapter', 'TestSection{}'.format(index)).add_children(
|
||||
XBlockFixtureDesc('sequential', 'TestSubsection{}'.format(index)).add_children(
|
||||
XBlockFixtureDesc('vertical', 'TestVertical{}'.format(index))
|
||||
)
|
||||
)
|
||||
]
|
||||
self.course_fixture.add_children(*xblocks).install()
|
||||
|
||||
def verify_event_data(self, event_type, event_data):
|
||||
"""
|
||||
Verify emitted event data.
|
||||
|
||||
Arguments:
|
||||
event_type: expected event type
|
||||
event_data: expected event data
|
||||
"""
|
||||
actual_events = self.wait_for_events(event_filter={'event_type': event_type}, number_of_matches=1)
|
||||
self.assert_events_match(event_data, actual_events)
|
||||
|
||||
|
||||
class BookmarksTest(BookmarksTestMixin):
|
||||
"""
|
||||
Tests to verify bookmarks functionality.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
"""
|
||||
Initialize test setup.
|
||||
"""
|
||||
super(BookmarksTest, self).setUp()
|
||||
|
||||
self.course_outline_page = CourseOutlinePage(
|
||||
self.browser,
|
||||
self.course_info['org'],
|
||||
self.course_info['number'],
|
||||
self.course_info['run']
|
||||
)
|
||||
|
||||
self.courseware_page = CoursewarePage(self.browser, self.course_id)
|
||||
self.bookmarks_page = BookmarksPage(self.browser, self.course_id)
|
||||
self.course_nav = CourseNavPage(self.browser)
|
||||
|
||||
# Get session to be used for bookmarking units
|
||||
self.session = requests.Session()
|
||||
params = {'username': self.USERNAME, 'email': self.EMAIL, 'course_id': self.course_id}
|
||||
response = self.session.get(BASE_URL + "/auto_auth", params=params)
|
||||
self.assertTrue(response.ok, "Failed to get session")
|
||||
|
||||
def _test_setup(self, num_chapters=2):
|
||||
"""
|
||||
Setup test settings.
|
||||
|
||||
Arguments:
|
||||
num_chapters: number of chapters to create in course
|
||||
"""
|
||||
self.create_course_fixture(num_chapters)
|
||||
|
||||
# Auto-auth register for the course.
|
||||
LmsAutoAuthPage(self.browser, username=self.USERNAME, email=self.EMAIL, course_id=self.course_id).visit()
|
||||
|
||||
self.courseware_page.visit()
|
||||
|
||||
def _bookmark_unit(self, location):
|
||||
"""
|
||||
Bookmark a unit
|
||||
|
||||
Arguments:
|
||||
location (str): unit location
|
||||
"""
|
||||
_headers = {
|
||||
'Content-type': 'application/json',
|
||||
'X-CSRFToken': self.session.cookies['csrftoken'],
|
||||
}
|
||||
params = {'course_id': self.course_id}
|
||||
data = json.dumps({'usage_id': location})
|
||||
response = self.session.post(
|
||||
BASE_URL + '/api/bookmarks/v1/bookmarks/',
|
||||
data=data,
|
||||
params=params,
|
||||
headers=_headers
|
||||
)
|
||||
self.assertTrue(response.ok, "Failed to bookmark unit")
|
||||
|
||||
def _bookmark_units(self, num_units):
|
||||
"""
|
||||
Bookmark first `num_units` units
|
||||
|
||||
Arguments:
|
||||
num_units(int): Number of units to bookmarks
|
||||
"""
|
||||
xblocks = self.course_fixture.get_nested_xblocks(category="vertical")
|
||||
for index in range(num_units):
|
||||
self._bookmark_unit(xblocks[index].locator)
|
||||
|
||||
def _breadcrumb(self, num_units, modified_name=None):
|
||||
"""
|
||||
Creates breadcrumbs for the first `num_units`
|
||||
|
||||
Arguments:
|
||||
num_units(int): Number of units for which we want to create breadcrumbs
|
||||
|
||||
Returns:
|
||||
list of breadcrumbs
|
||||
"""
|
||||
breadcrumbs = []
|
||||
for index in range(num_units):
|
||||
breadcrumbs.append(
|
||||
[
|
||||
'TestSection{}'.format(index),
|
||||
'TestSubsection{}'.format(index),
|
||||
modified_name if modified_name else 'TestVertical{}'.format(index)
|
||||
]
|
||||
)
|
||||
return breadcrumbs
|
||||
|
||||
def _delete_section(self, index):
|
||||
""" Delete a section at index `index` """
|
||||
|
||||
# Logout and login as staff
|
||||
LogoutPage(self.browser).visit()
|
||||
StudioAutoAuthPage(
|
||||
self.browser, username=self.USERNAME, email=self.EMAIL, course_id=self.course_id, staff=True
|
||||
).visit()
|
||||
|
||||
# Visit course outline page in studio.
|
||||
self.course_outline_page.visit()
|
||||
self.course_outline_page.wait_for_page()
|
||||
|
||||
self.course_outline_page.section_at(index).delete()
|
||||
|
||||
# Logout and login as a student.
|
||||
LogoutPage(self.browser).visit()
|
||||
LmsAutoAuthPage(self.browser, username=self.USERNAME, email=self.EMAIL, course_id=self.course_id).visit()
|
||||
|
||||
# Visit courseware as a student.
|
||||
self.courseware_page.visit()
|
||||
self.courseware_page.wait_for_page()
|
||||
|
||||
def _toggle_bookmark_and_verify(self, bookmark_icon_state, bookmark_button_state, bookmarked_count):
|
||||
"""
|
||||
Bookmark/Un-Bookmark a unit and then verify
|
||||
"""
|
||||
self.assertTrue(self.courseware_page.bookmark_button_visible)
|
||||
self.courseware_page.click_bookmark_unit_button()
|
||||
self.assertEqual(self.courseware_page.bookmark_icon_visible, bookmark_icon_state)
|
||||
self.assertEqual(self.courseware_page.bookmark_button_state, bookmark_button_state)
|
||||
self.bookmarks_page.click_bookmarks_button()
|
||||
self.assertEqual(self.bookmarks_page.count(), bookmarked_count)
|
||||
|
||||
def _verify_pagination_info(
|
||||
self,
|
||||
bookmark_count_on_current_page,
|
||||
header_text,
|
||||
previous_button_enabled,
|
||||
next_button_enabled,
|
||||
current_page_number,
|
||||
total_pages
|
||||
):
|
||||
"""
|
||||
Verify pagination info
|
||||
"""
|
||||
self.assertEqual(self.bookmarks_page.count(), bookmark_count_on_current_page)
|
||||
self.assertEqual(self.bookmarks_page.get_pagination_header_text(), header_text)
|
||||
self.assertEqual(self.bookmarks_page.is_previous_page_button_enabled(), previous_button_enabled)
|
||||
self.assertEqual(self.bookmarks_page.is_next_page_button_enabled(), next_button_enabled)
|
||||
self.assertEqual(self.bookmarks_page.get_current_page_number(), current_page_number)
|
||||
self.assertEqual(self.bookmarks_page.get_total_pages, total_pages)
|
||||
|
||||
def _navigate_to_bookmarks_list(self):
|
||||
"""
|
||||
Navigates and verifies the bookmarks list page.
|
||||
"""
|
||||
self.bookmarks_page.click_bookmarks_button()
|
||||
self.assertTrue(self.bookmarks_page.results_present())
|
||||
self.assertEqual(self.bookmarks_page.results_header_text(), 'My Bookmarks')
|
||||
|
||||
def _verify_breadcrumbs(self, num_units, modified_name=None):
|
||||
"""
|
||||
Verifies the breadcrumb trail.
|
||||
"""
|
||||
bookmarked_breadcrumbs = self.bookmarks_page.breadcrumbs()
|
||||
|
||||
# Verify bookmarked breadcrumbs.
|
||||
breadcrumbs = self._breadcrumb(num_units=num_units, modified_name=modified_name)
|
||||
breadcrumbs.reverse()
|
||||
self.assertEqual(bookmarked_breadcrumbs, breadcrumbs)
|
||||
|
||||
def update_and_publish_block_display_name(self, modified_name):
|
||||
"""
|
||||
Update and publish the block/unit display name.
|
||||
"""
|
||||
self.course_outline_page.visit()
|
||||
self.course_outline_page.wait_for_page()
|
||||
|
||||
self.course_outline_page.expand_all_subsections()
|
||||
section = self.course_outline_page.section_at(0)
|
||||
container_page = section.subsection_at(0).unit_at(0).go_to()
|
||||
|
||||
self.course_fixture._update_xblock(container_page.locator, { # pylint: disable=protected-access
|
||||
"metadata": {
|
||||
"display_name": modified_name
|
||||
}
|
||||
})
|
||||
|
||||
container_page.visit()
|
||||
container_page.wait_for_page()
|
||||
|
||||
self.assertEqual(container_page.name, modified_name)
|
||||
container_page.publish_action.click()
|
||||
|
||||
def test_bookmark_button(self):
|
||||
"""
|
||||
Scenario: Bookmark unit button toggles correctly
|
||||
|
||||
Given that I am a registered user
|
||||
And I visit my courseware page
|
||||
For first 2 units
|
||||
I visit the unit
|
||||
And I can see the Bookmark button
|
||||
When I click on Bookmark button
|
||||
Then unit should be bookmarked
|
||||
Then I click again on the bookmark button
|
||||
And I should see a unit un-bookmarked
|
||||
"""
|
||||
self._test_setup()
|
||||
for index in range(2):
|
||||
self.course_nav.go_to_section('TestSection{}'.format(index), 'TestSubsection{}'.format(index))
|
||||
|
||||
self._toggle_bookmark_and_verify(True, 'bookmarked', 1)
|
||||
self.bookmarks_page.click_bookmarks_button(False)
|
||||
self._toggle_bookmark_and_verify(False, '', 0)
|
||||
|
||||
def test_empty_bookmarks_list(self):
|
||||
"""
|
||||
Scenario: An empty bookmarks list is shown if there are no bookmarked units.
|
||||
|
||||
Given that I am a registered user
|
||||
And I visit my courseware page
|
||||
And I can see the Bookmarks button
|
||||
When I click on Bookmarks button
|
||||
Then I should see an empty bookmarks list
|
||||
And empty bookmarks list content is correct
|
||||
"""
|
||||
self._test_setup()
|
||||
self.assertTrue(self.bookmarks_page.bookmarks_button_visible())
|
||||
self.bookmarks_page.click_bookmarks_button()
|
||||
self.assertEqual(self.bookmarks_page.results_header_text(), 'My Bookmarks')
|
||||
self.assertEqual(self.bookmarks_page.empty_header_text(), 'You have not bookmarked any courseware pages yet.')
|
||||
|
||||
empty_list_text = ("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.")
|
||||
self.assertEqual(self.bookmarks_page.empty_list_text(), empty_list_text)
|
||||
|
||||
def test_bookmarks_list(self):
|
||||
"""
|
||||
Scenario: A bookmarks list is shown if there are bookmarked units.
|
||||
|
||||
Given that I am a registered user
|
||||
And I visit my courseware page
|
||||
And I have bookmarked 2 units
|
||||
When I click on Bookmarks button
|
||||
Then I should see a bookmarked list with 2 bookmark links
|
||||
And breadcrumb trail is correct for a bookmark
|
||||
When I click on bookmarked link
|
||||
Then I can navigate to correct bookmarked unit
|
||||
"""
|
||||
self._test_setup()
|
||||
self._bookmark_units(2)
|
||||
|
||||
self._navigate_to_bookmarks_list()
|
||||
self._verify_breadcrumbs(num_units=2)
|
||||
|
||||
self._verify_pagination_info(
|
||||
bookmark_count_on_current_page=2,
|
||||
header_text='Showing 1-2 out of 2 total',
|
||||
previous_button_enabled=False,
|
||||
next_button_enabled=False,
|
||||
current_page_number=1,
|
||||
total_pages=1
|
||||
)
|
||||
|
||||
# get usage ids for units
|
||||
xblocks = self.course_fixture.get_nested_xblocks(category="vertical")
|
||||
xblock_usage_ids = [xblock.locator for xblock in xblocks]
|
||||
# Verify link navigation
|
||||
for index in range(2):
|
||||
self.bookmarks_page.click_bookmarked_block(index)
|
||||
self.courseware_page.wait_for_page()
|
||||
self.assertIn(self.courseware_page.active_usage_id(), xblock_usage_ids)
|
||||
self.courseware_page.visit().wait_for_page()
|
||||
self.bookmarks_page.click_bookmarks_button()
|
||||
|
||||
def test_bookmark_shows_updated_breadcrumb_after_publish(self):
|
||||
"""
|
||||
Scenario: A bookmark breadcrumb trail is updated after publishing the changed display name.
|
||||
|
||||
Given that I am a registered user
|
||||
And I visit my courseware page
|
||||
And I can see bookmarked unit
|
||||
Then I visit unit page in studio
|
||||
Then I change unit display_name
|
||||
And I publish the changes
|
||||
Then I visit my courseware page
|
||||
And I visit bookmarks list page
|
||||
When I see the bookmark
|
||||
Then I can see the breadcrumb trail
|
||||
with updated display_name.
|
||||
"""
|
||||
self._test_setup(num_chapters=1)
|
||||
self._bookmark_units(num_units=1)
|
||||
|
||||
self._navigate_to_bookmarks_list()
|
||||
self._verify_breadcrumbs(num_units=1)
|
||||
|
||||
LogoutPage(self.browser).visit()
|
||||
LmsAutoAuthPage(
|
||||
self.browser,
|
||||
username=self.USERNAME,
|
||||
email=self.EMAIL,
|
||||
course_id=self.course_id,
|
||||
staff=True
|
||||
).visit()
|
||||
|
||||
modified_name = "Updated name"
|
||||
self.update_and_publish_block_display_name(modified_name)
|
||||
|
||||
LogoutPage(self.browser).visit()
|
||||
LmsAutoAuthPage(self.browser, username=self.USERNAME, email=self.EMAIL, course_id=self.course_id).visit()
|
||||
self.courseware_page.visit()
|
||||
|
||||
self._navigate_to_bookmarks_list()
|
||||
self._verify_breadcrumbs(num_units=1, modified_name=modified_name)
|
||||
|
||||
def test_unreachable_bookmark(self):
|
||||
"""
|
||||
Scenario: We should get a HTTP 404 for an unreachable bookmark.
|
||||
|
||||
Given that I am a registered user
|
||||
And I visit my courseware page
|
||||
And I have bookmarked 2 units
|
||||
Then I delete a bookmarked unit
|
||||
Then I click on Bookmarks button
|
||||
And I should see a bookmarked list
|
||||
When I click on deleted bookmark
|
||||
Then I should navigated to 404 page
|
||||
"""
|
||||
self._test_setup(num_chapters=1)
|
||||
self._bookmark_units(1)
|
||||
self._delete_section(0)
|
||||
|
||||
self._navigate_to_bookmarks_list()
|
||||
|
||||
self._verify_pagination_info(
|
||||
bookmark_count_on_current_page=1,
|
||||
header_text='Showing 1 out of 1 total',
|
||||
previous_button_enabled=False,
|
||||
next_button_enabled=False,
|
||||
current_page_number=1,
|
||||
total_pages=1
|
||||
)
|
||||
|
||||
self.bookmarks_page.click_bookmarked_block(0)
|
||||
self.assertTrue(is_404_page(self.browser))
|
||||
|
||||
def test_page_size_limit(self):
|
||||
"""
|
||||
Scenario: We can't get bookmarks more than default page size.
|
||||
|
||||
Given that I am a registered user
|
||||
And I visit my courseware page
|
||||
And I have bookmarked all the 11 units available
|
||||
Then I click on Bookmarks button
|
||||
And I should see a bookmarked list
|
||||
And bookmark list contains 10 bookmarked items
|
||||
"""
|
||||
self._test_setup(11)
|
||||
self._bookmark_units(11)
|
||||
self._navigate_to_bookmarks_list()
|
||||
|
||||
self._verify_pagination_info(
|
||||
bookmark_count_on_current_page=10,
|
||||
header_text='Showing 1-10 out of 11 total',
|
||||
previous_button_enabled=False,
|
||||
next_button_enabled=True,
|
||||
current_page_number=1,
|
||||
total_pages=2
|
||||
)
|
||||
|
||||
def test_pagination_with_single_page(self):
|
||||
"""
|
||||
Scenario: Bookmarks list pagination is working as expected for single page
|
||||
Given that I am a registered user
|
||||
And I visit my courseware page
|
||||
And I have bookmarked all the 2 units available
|
||||
Then I click on Bookmarks button
|
||||
And I should see a bookmarked list with 2 bookmarked items
|
||||
And I should see paging header and footer with correct data
|
||||
And previous and next buttons are disabled
|
||||
"""
|
||||
self._test_setup(num_chapters=2)
|
||||
self._bookmark_units(num_units=2)
|
||||
|
||||
self.bookmarks_page.click_bookmarks_button()
|
||||
self.assertTrue(self.bookmarks_page.results_present())
|
||||
self._verify_pagination_info(
|
||||
bookmark_count_on_current_page=2,
|
||||
header_text='Showing 1-2 out of 2 total',
|
||||
previous_button_enabled=False,
|
||||
next_button_enabled=False,
|
||||
current_page_number=1,
|
||||
total_pages=1
|
||||
)
|
||||
|
||||
def test_next_page_button(self):
|
||||
"""
|
||||
Scenario: Next button is working as expected for bookmarks list pagination
|
||||
|
||||
Given that I am a registered user
|
||||
And I visit my courseware page
|
||||
And I have bookmarked all the 12 units available
|
||||
|
||||
Then I click on Bookmarks button
|
||||
And I should see a bookmarked list of 10 items
|
||||
And I should see paging header and footer with correct info
|
||||
|
||||
Then I click on next page button in footer
|
||||
And I should be navigated to second page
|
||||
And I should see a bookmarked list with 2 items
|
||||
And I should see paging header and footer with correct info
|
||||
"""
|
||||
self._test_setup(num_chapters=12)
|
||||
self._bookmark_units(num_units=12)
|
||||
|
||||
self.bookmarks_page.click_bookmarks_button()
|
||||
self.assertTrue(self.bookmarks_page.results_present())
|
||||
|
||||
self._verify_pagination_info(
|
||||
bookmark_count_on_current_page=10,
|
||||
header_text='Showing 1-10 out of 12 total',
|
||||
previous_button_enabled=False,
|
||||
next_button_enabled=True,
|
||||
current_page_number=1,
|
||||
total_pages=2
|
||||
)
|
||||
|
||||
self.bookmarks_page.press_next_page_button()
|
||||
self._verify_pagination_info(
|
||||
bookmark_count_on_current_page=2,
|
||||
header_text='Showing 11-12 out of 12 total',
|
||||
previous_button_enabled=True,
|
||||
next_button_enabled=False,
|
||||
current_page_number=2,
|
||||
total_pages=2
|
||||
)
|
||||
|
||||
def test_previous_page_button(self):
|
||||
"""
|
||||
Scenario: Previous button is working as expected for bookmarks list pagination
|
||||
|
||||
Given that I am a registered user
|
||||
And I visit my courseware page
|
||||
And I have bookmarked all the 12 units available
|
||||
And I click on Bookmarks button
|
||||
|
||||
Then I click on next page button in footer
|
||||
And I should be navigated to second page
|
||||
And I should see a bookmarked list with 2 items
|
||||
And I should see paging header and footer with correct info
|
||||
|
||||
Then I click on previous page button
|
||||
And I should be navigated to first page
|
||||
And I should see paging header and footer with correct info
|
||||
"""
|
||||
self._test_setup(num_chapters=12)
|
||||
self._bookmark_units(num_units=12)
|
||||
|
||||
self.bookmarks_page.click_bookmarks_button()
|
||||
self.assertTrue(self.bookmarks_page.results_present())
|
||||
|
||||
self.bookmarks_page.press_next_page_button()
|
||||
self._verify_pagination_info(
|
||||
bookmark_count_on_current_page=2,
|
||||
header_text='Showing 11-12 out of 12 total',
|
||||
previous_button_enabled=True,
|
||||
next_button_enabled=False,
|
||||
current_page_number=2,
|
||||
total_pages=2
|
||||
)
|
||||
|
||||
self.bookmarks_page.press_previous_page_button()
|
||||
self._verify_pagination_info(
|
||||
bookmark_count_on_current_page=10,
|
||||
header_text='Showing 1-10 out of 12 total',
|
||||
previous_button_enabled=False,
|
||||
next_button_enabled=True,
|
||||
current_page_number=1,
|
||||
total_pages=2
|
||||
)
|
||||
|
||||
def test_pagination_with_valid_page_number(self):
|
||||
"""
|
||||
Scenario: Bookmarks list pagination works as expected for valid page number
|
||||
|
||||
Given that I am a registered user
|
||||
And I visit my courseware page
|
||||
And I have bookmarked all the 12 units available
|
||||
|
||||
Then I click on Bookmarks button
|
||||
And I should see a bookmarked list
|
||||
And I should see total page value is 2
|
||||
Then I enter 2 in the page number input
|
||||
And I should be navigated to page 2
|
||||
"""
|
||||
self._test_setup(num_chapters=11)
|
||||
self._bookmark_units(num_units=11)
|
||||
|
||||
self.bookmarks_page.click_bookmarks_button()
|
||||
self.assertTrue(self.bookmarks_page.results_present())
|
||||
self.assertEqual(self.bookmarks_page.get_total_pages, 2)
|
||||
|
||||
self.bookmarks_page.go_to_page(2)
|
||||
self._verify_pagination_info(
|
||||
bookmark_count_on_current_page=1,
|
||||
header_text='Showing 11-11 out of 11 total',
|
||||
previous_button_enabled=True,
|
||||
next_button_enabled=False,
|
||||
current_page_number=2,
|
||||
total_pages=2
|
||||
)
|
||||
|
||||
def test_pagination_with_invalid_page_number(self):
|
||||
"""
|
||||
Scenario: Bookmarks list pagination works as expected for invalid page number
|
||||
|
||||
Given that I am a registered user
|
||||
And I visit my courseware page
|
||||
And I have bookmarked all the 11 units available
|
||||
Then I click on Bookmarks button
|
||||
And I should see a bookmarked list
|
||||
And I should see total page value is 2
|
||||
Then I enter 3 in the page number input
|
||||
And I should stay at page 1
|
||||
"""
|
||||
self._test_setup(num_chapters=11)
|
||||
self._bookmark_units(num_units=11)
|
||||
|
||||
self.bookmarks_page.click_bookmarks_button()
|
||||
self.assertTrue(self.bookmarks_page.results_present())
|
||||
self.assertEqual(self.bookmarks_page.get_total_pages, 2)
|
||||
|
||||
self.bookmarks_page.go_to_page(3)
|
||||
self._verify_pagination_info(
|
||||
bookmark_count_on_current_page=10,
|
||||
header_text='Showing 1-10 out of 11 total',
|
||||
previous_button_enabled=False,
|
||||
next_button_enabled=True,
|
||||
current_page_number=1,
|
||||
total_pages=2
|
||||
)
|
||||
|
||||
def test_bookmarked_unit_accessed_event(self):
|
||||
"""
|
||||
Scenario: Bookmark events are emitted with correct data when we access/visit a bookmarked unit.
|
||||
|
||||
Given that I am a registered user
|
||||
And I visit my courseware page
|
||||
And I have bookmarked a unit
|
||||
When I click on bookmarked unit
|
||||
Then `edx.course.bookmark.accessed` event is emitted
|
||||
"""
|
||||
self._test_setup(num_chapters=1)
|
||||
self.reset_event_tracking()
|
||||
|
||||
# create expected event data
|
||||
xblocks = self.course_fixture.get_nested_xblocks(category="vertical")
|
||||
event_data = [
|
||||
{
|
||||
'event': {
|
||||
'bookmark_id': '{},{}'.format(self.USERNAME, xblocks[0].locator),
|
||||
'component_type': xblocks[0].category,
|
||||
'component_usage_id': xblocks[0].locator,
|
||||
}
|
||||
}
|
||||
]
|
||||
self._bookmark_units(num_units=1)
|
||||
self.bookmarks_page.click_bookmarks_button()
|
||||
|
||||
self._verify_pagination_info(
|
||||
bookmark_count_on_current_page=1,
|
||||
header_text='Showing 1 out of 1 total',
|
||||
previous_button_enabled=False,
|
||||
next_button_enabled=False,
|
||||
current_page_number=1,
|
||||
total_pages=1
|
||||
)
|
||||
|
||||
self.bookmarks_page.click_bookmarked_block(0)
|
||||
self.verify_event_data('edx.bookmark.accessed', event_data)
|
||||
@@ -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')
|
||||
)
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
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
|
||||
|
||||
12
lms/static/js/bookmarks/bookmarks_factory.js
Normal file
12
lms/static/js/bookmarks/bookmarks_factory.js
Normal file
@@ -0,0 +1,12 @@
|
||||
;(function (define) {
|
||||
'use strict';
|
||||
define([
|
||||
'js/bookmarks/views/bookmarks_list_button'
|
||||
],
|
||||
function(BookmarksListButton) {
|
||||
return function() {
|
||||
return new BookmarksListButton();
|
||||
};
|
||||
}
|
||||
);
|
||||
}).call(this, define || RequireJS.define);
|
||||
30
lms/static/js/bookmarks/collections/bookmarks.js
Normal file
30
lms/static/js/bookmarks/collections/bookmarks.js
Normal file
@@ -0,0 +1,30 @@
|
||||
;(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);
|
||||
|
||||
21
lms/static/js/bookmarks/models/bookmark.js
Normal file
21
lms/static/js/bookmarks/models/bookmark.js
Normal file
@@ -0,0 +1,21 @@
|
||||
;(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);
|
||||
102
lms/static/js/bookmarks/views/bookmark_button.js
Normal file
102
lms/static/js/bookmarks/views/bookmark_button.js
Normal file
@@ -0,0 +1,102 @@
|
||||
;(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);
|
||||
132
lms/static/js/bookmarks/views/bookmarks_list.js
Normal file
132
lms/static/js/bookmarks/views/bookmarks_list.js
Normal file
@@ -0,0 +1,132 @@
|
||||
;(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);
|
||||
48
lms/static/js/bookmarks/views/bookmarks_list_button.js
Normal file
48
lms/static/js/bookmarks/views/bookmarks_list_button.js
Normal file
@@ -0,0 +1,48 @@
|
||||
;(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);
|
||||
13
lms/static/js/fixtures/bookmarks/bookmark_button.html
Normal file
13
lms/static/js/fixtures/bookmarks/bookmark_button.html
Normal file
@@ -0,0 +1,13 @@
|
||||
|
||||
<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>
|
||||
Bookmark
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
10
lms/static/js/fixtures/bookmarks/bookmarks.html
Normal file
10
lms/static/js/fixtures/bookmarks/bookmarks.html
Normal file
@@ -0,0 +1,10 @@
|
||||
<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');
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
|
||||
156
lms/static/js/spec/bookmarks/bookmark_button_view_spec.js
Normal file
156
lms/static/js/spec/bookmarks/bookmark_button_view_spec.js
Normal file
@@ -0,0 +1,156 @@
|
||||
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('');
|
||||
});
|
||||
});
|
||||
});
|
||||
301
lms/static/js/spec/bookmarks/bookmarks_list_view_spec.js
Normal file
301
lms/static/js/spec/bookmarks/bookmarks_list_view_spec.js
Normal file
@@ -0,0 +1,301 @@
|
||||
define(['backbone',
|
||||
'jquery',
|
||||
'underscore',
|
||||
'logger',
|
||||
'URI',
|
||||
'common/js/spec_helpers/ajax_helpers',
|
||||
'common/js/spec_helpers/template_helpers',
|
||||
'js/bookmarks/views/bookmarks_list_button',
|
||||
'js/bookmarks/views/bookmarks_list',
|
||||
'js/bookmarks/collections/bookmarks'],
|
||||
function (Backbone, $, _, Logger, URI, AjaxHelpers, TemplateHelpers, BookmarksListButtonView, BookmarksListView,
|
||||
BookmarksCollection) {
|
||||
'use strict';
|
||||
|
||||
describe("lms.courseware.bookmarks", function () {
|
||||
|
||||
var bookmarksButtonView;
|
||||
|
||||
beforeEach(function () {
|
||||
loadFixtures('js/fixtures/bookmarks/bookmarks.html');
|
||||
TemplateHelpers.installTemplates(
|
||||
[
|
||||
'templates/fields/message_banner',
|
||||
'templates/bookmarks/bookmarks-list'
|
||||
]
|
||||
);
|
||||
spyOn(Logger, 'log').andReturn($.Deferred().resolve());
|
||||
this.addMatchers({
|
||||
toHaveBeenCalledWithUrl: function (expectedUrl) {
|
||||
return expectedUrl === this.actual.argsForCall[0][0].target.pathname;
|
||||
}
|
||||
});
|
||||
|
||||
bookmarksButtonView = new BookmarksListButtonView();
|
||||
});
|
||||
|
||||
var verifyRequestParams = function (requests, params) {
|
||||
var urlParams = (new URI(requests[requests.length - 1].url)).query(true);
|
||||
_.each(params, function (value, key) {
|
||||
expect(urlParams[key]).toBe(value);
|
||||
});
|
||||
};
|
||||
|
||||
var createBookmarksData = function (options) {
|
||||
var data = {
|
||||
count: options.count || 0,
|
||||
num_pages: options.num_pages || 1,
|
||||
current_page: options.current_page || 1,
|
||||
start: options.start || 0,
|
||||
results: []
|
||||
};
|
||||
|
||||
for(var i = 0; i < options.numBookmarksToCreate; i++) {
|
||||
var bookmarkInfo = {
|
||||
id: i,
|
||||
display_name: 'UNIT_DISPLAY_NAME_' + i,
|
||||
created: new Date().toISOString(),
|
||||
course_id: 'COURSE_ID',
|
||||
usage_id: 'UNIT_USAGE_ID_' + i,
|
||||
block_type: 'vertical',
|
||||
path: [
|
||||
{display_name: 'SECTION_DISAPLAY_NAME', usage_id: 'SECTION_USAGE_ID'},
|
||||
{display_name: 'SUBSECTION_DISAPLAY_NAME', usage_id: 'SUBSECTION_USAGE_ID'}
|
||||
]
|
||||
};
|
||||
|
||||
data.results.push(bookmarkInfo);
|
||||
}
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
var createBookmarkUrl = function (courseId, usageId) {
|
||||
return '/courses/' + courseId + '/jump_to/' + usageId;
|
||||
};
|
||||
|
||||
var breadcrumbTrail = function (path, unitDisplayName) {
|
||||
return _.pluck(path, 'display_name').
|
||||
concat([unitDisplayName]).
|
||||
join(' <i class="icon fa fa-caret-right" aria-hidden="true"></i><span class="sr">-</span> ');
|
||||
};
|
||||
|
||||
var verifyBookmarkedData = function (view, expectedData) {
|
||||
var courseId, usageId;
|
||||
var bookmarks = view.$('.bookmarks-results-list-item');
|
||||
var results = expectedData.results;
|
||||
|
||||
expect(bookmarks.length, results.length);
|
||||
|
||||
for(var bookmark_index = 0; bookmark_index < results.length; bookmark_index++) {
|
||||
courseId = results[bookmark_index].course_id;
|
||||
usageId = results[bookmark_index].usage_id;
|
||||
|
||||
expect(bookmarks[bookmark_index]).toHaveAttr('href', createBookmarkUrl(courseId, usageId));
|
||||
|
||||
expect($(bookmarks[bookmark_index]).data('bookmarkId')).toBe(bookmark_index);
|
||||
expect($(bookmarks[bookmark_index]).data('componentType')).toBe('vertical');
|
||||
expect($(bookmarks[bookmark_index]).data('usageId')).toBe(usageId);
|
||||
|
||||
expect($(bookmarks[bookmark_index]).find('.list-item-breadcrumbtrail').html().trim()).
|
||||
toBe(breadcrumbTrail(results[bookmark_index].path, results[bookmark_index].display_name));
|
||||
|
||||
expect($(bookmarks[bookmark_index]).find('.list-item-date').text().trim()).
|
||||
toBe('Bookmarked on ' + view.humanFriendlyDate(results[bookmark_index].created));
|
||||
}
|
||||
};
|
||||
|
||||
var verifyPaginationInfo = function (requests, expectedData, currentPage, headerMessage) {
|
||||
AjaxHelpers.respondWithJson(requests, expectedData);
|
||||
verifyBookmarkedData(bookmarksButtonView.bookmarksListView, expectedData);
|
||||
expect(bookmarksButtonView.bookmarksListView.$('.paging-footer span.current-page').text().trim()).
|
||||
toBe(currentPage);
|
||||
expect(bookmarksButtonView.bookmarksListView.$('.paging-header span').text().trim()).
|
||||
toBe(headerMessage);
|
||||
};
|
||||
|
||||
it("has correct behavior for bookmarks button", function () {
|
||||
var requests = AjaxHelpers.requests(this);
|
||||
|
||||
spyOn(bookmarksButtonView, 'toggleBookmarksListView').andCallThrough();
|
||||
|
||||
bookmarksButtonView.delegateEvents();
|
||||
|
||||
expect(bookmarksButtonView.$('.bookmarks-list-button')).toHaveAttr('aria-pressed', 'false');
|
||||
expect(bookmarksButtonView.$('.bookmarks-list-button')).toHaveClass('is-inactive');
|
||||
|
||||
bookmarksButtonView.$('.bookmarks-list-button').click();
|
||||
expect(bookmarksButtonView.toggleBookmarksListView).toHaveBeenCalled();
|
||||
expect(bookmarksButtonView.$('.bookmarks-list-button')).toHaveAttr('aria-pressed', 'true');
|
||||
expect(bookmarksButtonView.$('.bookmarks-list-button')).toHaveClass('is-active');
|
||||
AjaxHelpers.respondWithJson(requests, createBookmarksData({numBookmarksToCreate: 1}));
|
||||
|
||||
bookmarksButtonView.$('.bookmarks-list-button').click();
|
||||
expect(bookmarksButtonView.$('.bookmarks-list-button')).toHaveAttr('aria-pressed', 'false');
|
||||
expect(bookmarksButtonView.$('.bookmarks-list-button')).toHaveClass('is-inactive');
|
||||
});
|
||||
|
||||
it("can correctly render an empty bookmarks list", function () {
|
||||
var requests = AjaxHelpers.requests(this);
|
||||
var expectedData = createBookmarksData({numBookmarksToCreate: 0});
|
||||
|
||||
bookmarksButtonView.$('.bookmarks-list-button').click();
|
||||
AjaxHelpers.respondWithJson(requests, expectedData);
|
||||
|
||||
expect(bookmarksButtonView.bookmarksListView.$('.bookmarks-empty-header').text().trim()).
|
||||
toBe('You have not bookmarked any courseware pages yet.');
|
||||
|
||||
var emptyListText = "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.";
|
||||
|
||||
expect(bookmarksButtonView.bookmarksListView.$('.bookmarks-empty-detail-title').text().trim()).
|
||||
toBe(emptyListText);
|
||||
|
||||
expect(bookmarksButtonView.bookmarksListView.$('.paging-header').length).toBe(0);
|
||||
expect(bookmarksButtonView.bookmarksListView.$('.paging-footer').length).toBe(0);
|
||||
});
|
||||
|
||||
it("has rendered bookmarked list correctly", function () {
|
||||
var requests = AjaxHelpers.requests(this);
|
||||
var expectedData = createBookmarksData({numBookmarksToCreate: 3});
|
||||
|
||||
bookmarksButtonView.$('.bookmarks-list-button').click();
|
||||
|
||||
verifyRequestParams(
|
||||
requests,
|
||||
{course_id: 'a/b/c', fields: 'display_name,path', page: '1', page_size: '10'}
|
||||
);
|
||||
AjaxHelpers.respondWithJson(requests, expectedData);
|
||||
|
||||
expect(bookmarksButtonView.bookmarksListView.$('.bookmarks-results-header').text().trim()).
|
||||
toBe('My Bookmarks');
|
||||
|
||||
verifyBookmarkedData(bookmarksButtonView.bookmarksListView, expectedData);
|
||||
|
||||
expect(bookmarksButtonView.bookmarksListView.$('.paging-header').length).toBe(1);
|
||||
expect(bookmarksButtonView.bookmarksListView.$('.paging-footer').length).toBe(1);
|
||||
});
|
||||
|
||||
it("calls bookmarks list render on page_changed event", function () {
|
||||
var renderSpy = spyOn(BookmarksListView.prototype, 'render');
|
||||
var listView = new BookmarksListView({collection: new BookmarksCollection({course_id: 'abc'})});
|
||||
listView.collection.trigger('page_changed');
|
||||
expect(renderSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("can go to a page number", function () {
|
||||
var requests = AjaxHelpers.requests(this);
|
||||
var expectedData = createBookmarksData(
|
||||
{
|
||||
numBookmarksToCreate: 10,
|
||||
count: 12,
|
||||
num_pages: 2,
|
||||
current_page: 1,
|
||||
start: 0
|
||||
}
|
||||
);
|
||||
|
||||
bookmarksButtonView.$('.bookmarks-list-button').click();
|
||||
AjaxHelpers.respondWithJson(requests, expectedData);
|
||||
verifyBookmarkedData(bookmarksButtonView.bookmarksListView, expectedData);
|
||||
|
||||
bookmarksButtonView.bookmarksListView.$('input#page-number-input').val('2');
|
||||
bookmarksButtonView.bookmarksListView.$('input#page-number-input').trigger('change');
|
||||
|
||||
expectedData = createBookmarksData(
|
||||
{
|
||||
numBookmarksToCreate: 2,
|
||||
count: 12,
|
||||
num_pages: 2,
|
||||
current_page: 2,
|
||||
start: 10
|
||||
}
|
||||
);
|
||||
AjaxHelpers.respondWithJson(requests, expectedData);
|
||||
verifyBookmarkedData(bookmarksButtonView.bookmarksListView, expectedData);
|
||||
|
||||
expect(bookmarksButtonView.bookmarksListView.$('.paging-footer span.current-page').text().trim()).
|
||||
toBe('2');
|
||||
expect(bookmarksButtonView.bookmarksListView.$('.paging-header span').text().trim()).
|
||||
toBe('Showing 11-12 out of 12 total');
|
||||
});
|
||||
|
||||
it("can navigate forward and backward", function () {
|
||||
var requests = AjaxHelpers.requests(this);
|
||||
var expectedData = createBookmarksData(
|
||||
{
|
||||
numBookmarksToCreate: 10,
|
||||
count: 15,
|
||||
num_pages: 2,
|
||||
current_page: 1,
|
||||
start: 0
|
||||
}
|
||||
);
|
||||
|
||||
bookmarksButtonView.$('.bookmarks-list-button').click();
|
||||
verifyPaginationInfo(requests, expectedData, '1', 'Showing 1-10 out of 15 total');
|
||||
verifyRequestParams(
|
||||
requests,
|
||||
{course_id: 'a/b/c', fields: 'display_name,path', page: '1', page_size: '10'}
|
||||
);
|
||||
|
||||
bookmarksButtonView.bookmarksListView.$('.paging-footer .next-page-link').click();
|
||||
expectedData = createBookmarksData(
|
||||
{
|
||||
numBookmarksToCreate: 5,
|
||||
count: 15,
|
||||
num_pages: 2,
|
||||
current_page: 2,
|
||||
start: 10
|
||||
}
|
||||
);
|
||||
verifyPaginationInfo(requests, expectedData, '2', 'Showing 11-15 out of 15 total');
|
||||
verifyRequestParams(
|
||||
requests,
|
||||
{course_id: 'a/b/c', fields: 'display_name,path', page: '2', page_size: '10'}
|
||||
);
|
||||
|
||||
expectedData = createBookmarksData(
|
||||
{
|
||||
numBookmarksToCreate: 10,
|
||||
count: 15,
|
||||
num_pages: 2,
|
||||
current_page: 1,
|
||||
start: 0
|
||||
}
|
||||
);
|
||||
bookmarksButtonView.bookmarksListView.$('.paging-footer .previous-page-link').click();
|
||||
verifyPaginationInfo(requests, expectedData, '1', 'Showing 1-10 out of 15 total');
|
||||
verifyRequestParams(
|
||||
requests,
|
||||
{course_id: 'a/b/c', fields: 'display_name,path', page: '1', page_size: '10'}
|
||||
);
|
||||
});
|
||||
|
||||
it("can navigate to correct url", function () {
|
||||
var requests = AjaxHelpers.requests(this);
|
||||
spyOn(bookmarksButtonView.bookmarksListView, 'visitBookmark');
|
||||
|
||||
bookmarksButtonView.$('.bookmarks-list-button').click();
|
||||
AjaxHelpers.respondWithJson(requests, createBookmarksData({numBookmarksToCreate: 1}));
|
||||
|
||||
bookmarksButtonView.bookmarksListView.$('.bookmarks-results-list-item').click();
|
||||
var url = bookmarksButtonView.bookmarksListView.$('.bookmarks-results-list-item').attr('href');
|
||||
expect(bookmarksButtonView.bookmarksListView.visitBookmark).toHaveBeenCalledWithUrl(url);
|
||||
});
|
||||
|
||||
it("shows an error message for HTTP 500", function () {
|
||||
var requests = AjaxHelpers.requests(this);
|
||||
|
||||
bookmarksButtonView.$('.bookmarks-list-button').click();
|
||||
|
||||
AjaxHelpers.respondWithError(requests);
|
||||
|
||||
expect(bookmarksButtonView.bookmarksListView.$('.bookmarks-results-header').text().trim()).not
|
||||
.toBe('My Bookmarks');
|
||||
expect($('#error-message').text().trim()).toBe(bookmarksButtonView.bookmarksListView.errorMessage);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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([
|
||||
});
|
||||
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
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);
|
||||
}
|
||||
|
||||
174
lms/static/sass/views/_bookmarks.scss
Normal file
174
lms/static/sass/views/_bookmarks.scss
Normal file
@@ -0,0 +1,174 @@
|
||||
// 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
|
||||
|
||||
11
lms/templates/bookmark_button.html
Normal file
11
lms/templates/bookmark_button.html
Normal file
@@ -0,0 +1,11 @@
|
||||
<%! 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>
|
||||
${_("Bookmark")}
|
||||
</button>
|
||||
</div>
|
||||
43
lms/templates/bookmarks/bookmarks-list.underscore
Normal file
43
lms/templates/bookmarks/bookmarks-list.underscore
Normal file
@@ -0,0 +1,43 @@
|
||||
<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">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<%! 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"> ${_("Bookmarked") if item['bookmarked'] else ""}</span></p>
|
||||
</a>
|
||||
</li>
|
||||
% endfor
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
|
||||
% 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')),
|
||||
|
||||
|
||||
20
openedx/core/djangoapps/bookmarks/__init__.py
Normal file
20
openedx/core/djangoapps/bookmarks/__init__.py
Normal file
@@ -0,0 +1,20 @@
|
||||
"""
|
||||
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'])
|
||||
170
openedx/core/djangoapps/bookmarks/api.py
Normal file
170
openedx/core/djangoapps/bookmarks/api.py
Normal file
@@ -0,0 +1,170 @@
|
||||
"""
|
||||
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),
|
||||
}
|
||||
)
|
||||
55
openedx/core/djangoapps/bookmarks/migrations/0001_initial.py
Normal file
55
openedx/core/djangoapps/bookmarks/migrations/0001_initial.py
Normal file
@@ -0,0 +1,55 @@
|
||||
# -*- 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')]),
|
||||
),
|
||||
]
|
||||
250
openedx/core/djangoapps/bookmarks/models.py
Normal file
250
openedx/core/djangoapps/bookmarks/models.py
Normal file
@@ -0,0 +1,250 @@
|
||||
"""
|
||||
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
|
||||
63
openedx/core/djangoapps/bookmarks/serializers.py
Normal file
63
openedx/core/djangoapps/bookmarks/serializers.py
Normal file
@@ -0,0 +1,63 @@
|
||||
"""
|
||||
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
|
||||
138
openedx/core/djangoapps/bookmarks/services.py
Normal file
138
openedx/core/djangoapps/bookmarks/services.py
Normal file
@@ -0,0 +1,138 @@
|
||||
"""
|
||||
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
|
||||
20
openedx/core/djangoapps/bookmarks/signals.py
Normal file
20
openedx/core/djangoapps/bookmarks/signals.py
Normal file
@@ -0,0 +1,20 @@
|
||||
"""
|
||||
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)
|
||||
5
openedx/core/djangoapps/bookmarks/startup.py
Normal file
5
openedx/core/djangoapps/bookmarks/startup.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""
|
||||
Setup the signals on startup.
|
||||
"""
|
||||
|
||||
from . import signals # pylint: disable=unused-import
|
||||
159
openedx/core/djangoapps/bookmarks/tasks.py
Normal file
159
openedx/core/djangoapps/bookmarks/tasks.py
Normal file
@@ -0,0 +1,159 @@
|
||||
"""
|
||||
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)
|
||||
0
openedx/core/djangoapps/bookmarks/tests/__init__.py
Normal file
0
openedx/core/djangoapps/bookmarks/tests/__init__.py
Normal file
43
openedx/core/djangoapps/bookmarks/tests/factories.py
Normal file
43
openedx/core/djangoapps/bookmarks/tests/factories.py
Normal file
@@ -0,0 +1,43 @@
|
||||
"""
|
||||
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()
|
||||
239
openedx/core/djangoapps/bookmarks/tests/test_api.py
Normal file
239
openedx/core/djangoapps/bookmarks/tests/test_api.py
Normal file
@@ -0,0 +1,239 @@
|
||||
"""
|
||||
Tests for bookmarks api.
|
||||
"""
|
||||
import ddt
|
||||
from mock import patch
|
||||
from unittest import skipUnless
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
|
||||
from opaque_keys.edx.keys import UsageKey
|
||||
|
||||
from xmodule.modulestore.exceptions import ItemNotFoundError
|
||||
|
||||
from .. import api
|
||||
from ..models import Bookmark
|
||||
from openedx.core.djangoapps.bookmarks.api import BookmarksLimitReachedError
|
||||
from .test_models import BookmarksTestsBase
|
||||
|
||||
|
||||
class BookmarkApiEventTestMixin(object):
|
||||
""" Mixin for verifying that bookmark api events were emitted during a test. """
|
||||
|
||||
def assert_bookmark_event_emitted(self, mock_tracker, event_name, **kwargs):
|
||||
""" Assert that an event has been emitted. """
|
||||
mock_tracker.assert_any_call(
|
||||
event_name,
|
||||
kwargs,
|
||||
)
|
||||
|
||||
def assert_no_events_were_emitted(self, mock_tracker):
|
||||
"""
|
||||
Assert no events were emitted.
|
||||
"""
|
||||
self.assertFalse(mock_tracker.called) # pylint: disable=maybe-no-member
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
@skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Tests only valid in LMS')
|
||||
class BookmarksAPITests(BookmarkApiEventTestMixin, BookmarksTestsBase):
|
||||
"""
|
||||
These tests cover the parts of the API methods.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super(BookmarksAPITests, self).setUp()
|
||||
|
||||
def test_get_bookmark(self):
|
||||
"""
|
||||
Verifies that get_bookmark returns data as expected.
|
||||
"""
|
||||
bookmark_data = api.get_bookmark(user=self.user, usage_key=self.sequential_1.location)
|
||||
self.assert_bookmark_data_is_valid(self.bookmark_1, bookmark_data)
|
||||
|
||||
# With Optional fields.
|
||||
with self.assertNumQueries(1):
|
||||
bookmark_data = api.get_bookmark(
|
||||
user=self.user,
|
||||
usage_key=self.sequential_1.location,
|
||||
fields=self.ALL_FIELDS
|
||||
)
|
||||
self.assert_bookmark_data_is_valid(self.bookmark_1, bookmark_data, check_optional_fields=True)
|
||||
|
||||
def test_get_bookmark_raises_error(self):
|
||||
"""
|
||||
Verifies that get_bookmark raises error as expected.
|
||||
"""
|
||||
with self.assertNumQueries(1):
|
||||
with self.assertRaises(ObjectDoesNotExist):
|
||||
api.get_bookmark(user=self.other_user, usage_key=self.vertical_1.location)
|
||||
|
||||
@ddt.data(
|
||||
1, 10, 20
|
||||
)
|
||||
def test_get_bookmarks(self, count):
|
||||
"""
|
||||
Verifies that get_bookmarks returns data as expected.
|
||||
"""
|
||||
course, __, bookmarks = self.create_course_with_bookmarks_count(count)
|
||||
|
||||
# Without course key.
|
||||
with self.assertNumQueries(1):
|
||||
bookmarks_data = api.get_bookmarks(user=self.user)
|
||||
self.assertEqual(len(bookmarks_data), count + 3)
|
||||
# Assert them in ordered manner.
|
||||
self.assert_bookmark_data_is_valid(bookmarks[-1], bookmarks_data[0])
|
||||
self.assert_bookmark_data_is_valid(self.bookmark_1, bookmarks_data[-1])
|
||||
self.assert_bookmark_data_is_valid(self.bookmark_2, bookmarks_data[-2])
|
||||
|
||||
# Without course key, with optional fields.
|
||||
with self.assertNumQueries(1):
|
||||
bookmarks_data = api.get_bookmarks(user=self.user, fields=self.ALL_FIELDS)
|
||||
self.assertEqual(len(bookmarks_data), count + 3)
|
||||
self.assert_bookmark_data_is_valid(bookmarks[-1], bookmarks_data[0])
|
||||
self.assert_bookmark_data_is_valid(self.bookmark_1, bookmarks_data[-1])
|
||||
|
||||
# With course key.
|
||||
with self.assertNumQueries(1):
|
||||
bookmarks_data = api.get_bookmarks(user=self.user, course_key=course.id)
|
||||
self.assertEqual(len(bookmarks_data), count)
|
||||
self.assert_bookmark_data_is_valid(bookmarks[-1], bookmarks_data[0])
|
||||
self.assert_bookmark_data_is_valid(bookmarks[0], bookmarks_data[-1])
|
||||
|
||||
# With course key, with optional fields.
|
||||
with self.assertNumQueries(1):
|
||||
bookmarks_data = api.get_bookmarks(user=self.user, course_key=course.id, fields=self.ALL_FIELDS)
|
||||
self.assertEqual(len(bookmarks_data), count)
|
||||
self.assert_bookmark_data_is_valid(bookmarks[-1], bookmarks_data[0])
|
||||
self.assert_bookmark_data_is_valid(bookmarks[0], bookmarks_data[-1])
|
||||
|
||||
# Without Serialized.
|
||||
with self.assertNumQueries(1):
|
||||
bookmarks = api.get_bookmarks(user=self.user, course_key=course.id, serialized=False)
|
||||
self.assertEqual(len(bookmarks), count)
|
||||
self.assertTrue(bookmarks.model is Bookmark) # pylint: disable=no-member
|
||||
|
||||
@patch('openedx.core.djangoapps.bookmarks.api.tracker.emit')
|
||||
def test_create_bookmark(self, mock_tracker):
|
||||
"""
|
||||
Verifies that create_bookmark create & returns data as expected.
|
||||
"""
|
||||
self.assertEqual(len(api.get_bookmarks(user=self.user, course_key=self.course.id)), 2)
|
||||
|
||||
with self.assertNumQueries(9):
|
||||
bookmark_data = api.create_bookmark(user=self.user, usage_key=self.vertical_2.location)
|
||||
|
||||
self.assert_bookmark_event_emitted(
|
||||
mock_tracker,
|
||||
event_name='edx.bookmark.added',
|
||||
course_id=unicode(self.course_id),
|
||||
bookmark_id=bookmark_data['id'],
|
||||
component_type=self.vertical_2.location.block_type,
|
||||
component_usage_id=unicode(self.vertical_2.location),
|
||||
)
|
||||
|
||||
self.assertEqual(len(api.get_bookmarks(user=self.user, course_key=self.course.id)), 3)
|
||||
|
||||
@patch('openedx.core.djangoapps.bookmarks.api.tracker.emit')
|
||||
def test_create_bookmark_do_not_create_duplicates(self, mock_tracker):
|
||||
"""
|
||||
Verifies that create_bookmark do not create duplicate bookmarks.
|
||||
"""
|
||||
self.assertEqual(len(api.get_bookmarks(user=self.user, course_key=self.course.id)), 2)
|
||||
|
||||
with self.assertNumQueries(9):
|
||||
bookmark_data = api.create_bookmark(user=self.user, usage_key=self.vertical_2.location)
|
||||
|
||||
self.assert_bookmark_event_emitted(
|
||||
mock_tracker,
|
||||
event_name='edx.bookmark.added',
|
||||
course_id=unicode(self.course_id),
|
||||
bookmark_id=bookmark_data['id'],
|
||||
component_type=self.vertical_2.location.block_type,
|
||||
component_usage_id=unicode(self.vertical_2.location),
|
||||
)
|
||||
|
||||
self.assertEqual(len(api.get_bookmarks(user=self.user, course_key=self.course.id)), 3)
|
||||
|
||||
mock_tracker.reset_mock()
|
||||
|
||||
with self.assertNumQueries(5):
|
||||
bookmark_data_2 = api.create_bookmark(user=self.user, usage_key=self.vertical_2.location)
|
||||
|
||||
self.assertEqual(len(api.get_bookmarks(user=self.user, course_key=self.course.id)), 3)
|
||||
self.assertEqual(bookmark_data, bookmark_data_2)
|
||||
|
||||
self.assert_no_events_were_emitted(mock_tracker)
|
||||
|
||||
@patch('openedx.core.djangoapps.bookmarks.api.tracker.emit')
|
||||
def test_create_bookmark_raises_error(self, mock_tracker):
|
||||
"""
|
||||
Verifies that create_bookmark raises error as expected.
|
||||
"""
|
||||
with self.assertNumQueries(0):
|
||||
with self.assertRaises(ItemNotFoundError):
|
||||
api.create_bookmark(user=self.user, usage_key=UsageKey.from_string('i4x://brb/100/html/340ef1771a0940'))
|
||||
|
||||
self.assert_no_events_were_emitted(mock_tracker)
|
||||
|
||||
@patch('openedx.core.djangoapps.bookmarks.api.tracker.emit')
|
||||
@patch('django.conf.settings.MAX_BOOKMARKS_PER_COURSE', 5)
|
||||
def bookmark_more_than_limit_raise_error(self, mock_tracker):
|
||||
"""
|
||||
Verifies that create_bookmark raises error when maximum number of units
|
||||
allowed to bookmark per course are already bookmarked.
|
||||
"""
|
||||
|
||||
max_bookmarks = settings.MAX_BOOKMARKS_PER_COURSE
|
||||
__, blocks, __ = self.create_course_with_bookmarks_count(max_bookmarks)
|
||||
with self.assertNumQueries(1):
|
||||
with self.assertRaises(BookmarksLimitReachedError):
|
||||
api.create_bookmark(user=self.user, usage_key=blocks[-1].location)
|
||||
|
||||
self.assert_no_events_were_emitted(mock_tracker)
|
||||
|
||||
# if user tries to create bookmark in another course it should succeed
|
||||
self.assertEqual(len(api.get_bookmarks(user=self.user, course_key=self.other_course.id)), 1)
|
||||
api.create_bookmark(user=self.user, usage_key=self.other_chapter_1.location)
|
||||
self.assertEqual(len(api.get_bookmarks(user=self.user, course_key=self.other_course.id)), 2)
|
||||
|
||||
# if another user tries to create bookmark it should succeed
|
||||
self.assertEqual(len(api.get_bookmarks(user=self.other_user, course_key=blocks[-1].location.course_key)), 0)
|
||||
api.create_bookmark(user=self.other_user, usage_key=blocks[-1].location)
|
||||
self.assertEqual(len(api.get_bookmarks(user=self.other_user, course_key=blocks[-1].location.course_key)), 1)
|
||||
|
||||
@patch('openedx.core.djangoapps.bookmarks.api.tracker.emit')
|
||||
def test_delete_bookmark(self, mock_tracker):
|
||||
"""
|
||||
Verifies that delete_bookmark removes bookmark as expected.
|
||||
"""
|
||||
self.assertEqual(len(api.get_bookmarks(user=self.user)), 3)
|
||||
|
||||
with self.assertNumQueries(3):
|
||||
api.delete_bookmark(user=self.user, usage_key=self.sequential_1.location)
|
||||
|
||||
self.assert_bookmark_event_emitted(
|
||||
mock_tracker,
|
||||
event_name='edx.bookmark.removed',
|
||||
course_id=unicode(self.course_id),
|
||||
bookmark_id=self.bookmark_1.resource_id,
|
||||
component_type=self.sequential_1.location.block_type,
|
||||
component_usage_id=unicode(self.sequential_1.location),
|
||||
)
|
||||
|
||||
bookmarks_data = api.get_bookmarks(user=self.user)
|
||||
self.assertEqual(len(bookmarks_data), 2)
|
||||
self.assertNotEqual(unicode(self.sequential_1.location), bookmarks_data[0]['usage_id'])
|
||||
self.assertNotEqual(unicode(self.sequential_1.location), bookmarks_data[1]['usage_id'])
|
||||
|
||||
@patch('openedx.core.djangoapps.bookmarks.api.tracker.emit')
|
||||
def test_delete_bookmark_raises_error(self, mock_tracker):
|
||||
"""
|
||||
Verifies that delete_bookmark raises error as expected.
|
||||
"""
|
||||
with self.assertNumQueries(1):
|
||||
with self.assertRaises(ObjectDoesNotExist):
|
||||
api.delete_bookmark(user=self.other_user, usage_key=self.vertical_1.location)
|
||||
|
||||
self.assert_no_events_were_emitted(mock_tracker)
|
||||
475
openedx/core/djangoapps/bookmarks/tests/test_models.py
Normal file
475
openedx/core/djangoapps/bookmarks/tests/test_models.py
Normal file
@@ -0,0 +1,475 @@
|
||||
"""
|
||||
Tests for Bookmarks models.
|
||||
"""
|
||||
from contextlib import contextmanager
|
||||
import datetime
|
||||
import ddt
|
||||
from freezegun import freeze_time
|
||||
import mock
|
||||
import pytz
|
||||
from unittest import skipUnless
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from opaque_keys.edx.keys import UsageKey
|
||||
from opaque_keys.edx.locator import CourseLocator, BlockUsageLocator
|
||||
from xmodule.modulestore import ModuleStoreEnum
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.modulestore.tests.factories import check_mongo_calls, CourseFactory, ItemFactory
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||
|
||||
from student.tests.factories import AdminFactory, UserFactory
|
||||
|
||||
from .. import DEFAULT_FIELDS, OPTIONAL_FIELDS, PathItem
|
||||
from ..models import Bookmark, XBlockCache, parse_path_data
|
||||
from .factories import BookmarkFactory
|
||||
|
||||
EXAMPLE_USAGE_KEY_1 = u'i4x://org.15/course_15/chapter/Week_1'
|
||||
EXAMPLE_USAGE_KEY_2 = u'i4x://org.15/course_15/chapter/Week_2'
|
||||
|
||||
|
||||
noop_contextmanager = contextmanager(lambda x: (yield)) # pylint: disable=invalid-name
|
||||
|
||||
|
||||
class BookmarksTestsBase(ModuleStoreTestCase):
|
||||
"""
|
||||
Test the Bookmark model.
|
||||
"""
|
||||
ALL_FIELDS = DEFAULT_FIELDS + OPTIONAL_FIELDS
|
||||
STORE_TYPE = ModuleStoreEnum.Type.mongo
|
||||
TEST_PASSWORD = 'test'
|
||||
|
||||
def setUp(self):
|
||||
super(BookmarksTestsBase, self).setUp()
|
||||
|
||||
self.admin = AdminFactory()
|
||||
self.user = UserFactory.create(password=self.TEST_PASSWORD)
|
||||
self.other_user = UserFactory.create(password=self.TEST_PASSWORD)
|
||||
self.setup_test_data(self.STORE_TYPE)
|
||||
|
||||
def setup_test_data(self, store_type=ModuleStoreEnum.Type.mongo):
|
||||
""" Create courses and add some test blocks. """
|
||||
|
||||
with self.store.default_store(store_type):
|
||||
|
||||
self.course = CourseFactory.create(display_name='An Introduction to API Testing')
|
||||
self.course_id = unicode(self.course.id)
|
||||
|
||||
with self.store.bulk_operations(self.course.id):
|
||||
|
||||
self.chapter_1 = ItemFactory.create(
|
||||
parent_location=self.course.location, category='chapter', display_name='Week 1'
|
||||
)
|
||||
self.chapter_2 = ItemFactory.create(
|
||||
parent_location=self.course.location, category='chapter', display_name='Week 2'
|
||||
)
|
||||
|
||||
self.sequential_1 = ItemFactory.create(
|
||||
parent_location=self.chapter_1.location, category='sequential', display_name='Lesson 1'
|
||||
)
|
||||
self.sequential_2 = ItemFactory.create(
|
||||
parent_location=self.chapter_1.location, category='sequential', display_name='Lesson 2'
|
||||
)
|
||||
|
||||
self.vertical_1 = ItemFactory.create(
|
||||
parent_location=self.sequential_1.location, category='vertical', display_name='Subsection 1'
|
||||
)
|
||||
self.vertical_2 = ItemFactory.create(
|
||||
parent_location=self.sequential_2.location, category='vertical', display_name='Subsection 2'
|
||||
)
|
||||
self.vertical_3 = ItemFactory.create(
|
||||
parent_location=self.sequential_2.location, category='vertical', display_name='Subsection 3'
|
||||
)
|
||||
|
||||
self.html_1 = ItemFactory.create(
|
||||
parent_location=self.vertical_2.location, category='html', display_name='Details 1'
|
||||
)
|
||||
|
||||
self.path = [
|
||||
PathItem(self.chapter_1.location, self.chapter_1.display_name),
|
||||
PathItem(self.sequential_2.location, self.sequential_2.display_name),
|
||||
]
|
||||
|
||||
self.bookmark_1 = BookmarkFactory.create(
|
||||
user=self.user,
|
||||
course_key=self.course_id,
|
||||
usage_key=self.sequential_1.location,
|
||||
xblock_cache=XBlockCache.create({
|
||||
'display_name': self.sequential_1.display_name,
|
||||
'usage_key': self.sequential_1.location,
|
||||
}),
|
||||
)
|
||||
self.bookmark_2 = BookmarkFactory.create(
|
||||
user=self.user,
|
||||
course_key=self.course_id,
|
||||
usage_key=self.sequential_2.location,
|
||||
xblock_cache=XBlockCache.create({
|
||||
'display_name': self.sequential_2.display_name,
|
||||
'usage_key': self.sequential_2.location,
|
||||
}),
|
||||
)
|
||||
|
||||
self.other_course = CourseFactory.create(display_name='An Introduction to API Testing 2')
|
||||
|
||||
with self.store.bulk_operations(self.other_course.id):
|
||||
|
||||
self.other_chapter_1 = ItemFactory.create(
|
||||
parent_location=self.other_course.location, category='chapter', display_name='Other Week 1'
|
||||
)
|
||||
self.other_sequential_1 = ItemFactory.create(
|
||||
parent_location=self.other_chapter_1.location, category='sequential', display_name='Other Lesson 1'
|
||||
)
|
||||
self.other_sequential_2 = ItemFactory.create(
|
||||
parent_location=self.other_chapter_1.location, category='sequential', display_name='Other Lesson 2'
|
||||
)
|
||||
self.other_vertical_1 = ItemFactory.create(
|
||||
parent_location=self.other_sequential_1.location, category='vertical', display_name='Other Subsection 1'
|
||||
)
|
||||
self.other_vertical_2 = ItemFactory.create(
|
||||
parent_location=self.other_sequential_1.location, category='vertical', display_name='Other Subsection 2'
|
||||
)
|
||||
|
||||
# self.other_vertical_1 has two parents
|
||||
self.other_sequential_2.children.append(self.other_vertical_1.location)
|
||||
modulestore().update_item(self.other_sequential_2, self.admin.id) # pylint: disable=no-member
|
||||
|
||||
self.other_bookmark_1 = BookmarkFactory.create(
|
||||
user=self.user,
|
||||
course_key=unicode(self.other_course.id),
|
||||
usage_key=self.other_vertical_1.location,
|
||||
xblock_cache=XBlockCache.create({
|
||||
'display_name': self.other_vertical_1.display_name,
|
||||
'usage_key': self.other_vertical_1.location,
|
||||
}),
|
||||
)
|
||||
|
||||
def create_course_with_blocks(self, children_per_block=1, depth=1, store_type=ModuleStoreEnum.Type.mongo):
|
||||
"""
|
||||
Create a course and add blocks.
|
||||
"""
|
||||
with self.store.default_store(store_type):
|
||||
|
||||
course = CourseFactory.create()
|
||||
display_name = 0
|
||||
|
||||
with self.store.bulk_operations(course.id):
|
||||
blocks_at_next_level = [course]
|
||||
|
||||
for __ in range(depth):
|
||||
blocks_at_current_level = blocks_at_next_level
|
||||
blocks_at_next_level = []
|
||||
|
||||
for block in blocks_at_current_level:
|
||||
for __ in range(children_per_block):
|
||||
blocks_at_next_level += [ItemFactory.create(
|
||||
parent_location=block.scope_ids.usage_id, display_name=unicode(display_name)
|
||||
)]
|
||||
display_name += 1
|
||||
|
||||
return course
|
||||
|
||||
def create_course_with_bookmarks_count(self, count, store_type=ModuleStoreEnum.Type.mongo):
|
||||
"""
|
||||
Create a course, add some content and add bookmarks.
|
||||
"""
|
||||
with self.store.default_store(store_type):
|
||||
|
||||
course = CourseFactory.create()
|
||||
|
||||
with self.store.bulk_operations(course.id):
|
||||
blocks = [ItemFactory.create(
|
||||
parent_location=course.location, category='chapter', display_name=unicode(index)
|
||||
) for index in range(count)]
|
||||
|
||||
bookmarks = [BookmarkFactory.create(
|
||||
user=self.user,
|
||||
course_key=course.id,
|
||||
usage_key=block.location,
|
||||
xblock_cache=XBlockCache.create({
|
||||
'display_name': block.display_name,
|
||||
'usage_key': block.location,
|
||||
}),
|
||||
) for block in blocks]
|
||||
|
||||
return course, blocks, bookmarks
|
||||
|
||||
def assert_bookmark_model_is_valid(self, bookmark, bookmark_data):
|
||||
"""
|
||||
Assert that the attributes of the bookmark model were set correctly.
|
||||
"""
|
||||
self.assertEqual(bookmark.user, bookmark_data['user'])
|
||||
self.assertEqual(bookmark.course_key, bookmark_data['course_key'])
|
||||
self.assertEqual(unicode(bookmark.usage_key), unicode(bookmark_data['usage_key']))
|
||||
self.assertEqual(bookmark.resource_id, u"{},{}".format(bookmark_data['user'], bookmark_data['usage_key']))
|
||||
self.assertEqual(bookmark.display_name, bookmark_data['display_name'])
|
||||
self.assertEqual(bookmark.path, self.path)
|
||||
self.assertIsNotNone(bookmark.created)
|
||||
|
||||
self.assertEqual(bookmark.xblock_cache.course_key, bookmark_data['course_key'])
|
||||
self.assertEqual(bookmark.xblock_cache.display_name, bookmark_data['display_name'])
|
||||
|
||||
def assert_bookmark_data_is_valid(self, bookmark, bookmark_data, check_optional_fields=False):
|
||||
"""
|
||||
Assert that the bookmark data matches the data in the model.
|
||||
"""
|
||||
self.assertEqual(bookmark_data['id'], bookmark.resource_id)
|
||||
self.assertEqual(bookmark_data['course_id'], unicode(bookmark.course_key))
|
||||
self.assertEqual(bookmark_data['usage_id'], unicode(bookmark.usage_key))
|
||||
self.assertEqual(bookmark_data['block_type'], unicode(bookmark.usage_key.block_type))
|
||||
self.assertIsNotNone(bookmark_data['created'])
|
||||
|
||||
if check_optional_fields:
|
||||
self.assertEqual(bookmark_data['display_name'], bookmark.display_name)
|
||||
self.assertEqual(bookmark_data['path'], bookmark.path)
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
@skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Tests only valid in LMS')
|
||||
class BookmarkModelTests(BookmarksTestsBase):
|
||||
"""
|
||||
Test the Bookmark model.
|
||||
"""
|
||||
def get_bookmark_data(self, block, user=None):
|
||||
"""
|
||||
Returns bookmark data for testing.
|
||||
"""
|
||||
return {
|
||||
'user': user or self.user,
|
||||
'usage_key': block.location,
|
||||
'course_key': block.location.course_key,
|
||||
'display_name': block.display_name,
|
||||
}
|
||||
|
||||
@ddt.data(
|
||||
(ModuleStoreEnum.Type.mongo, 'course', [], 3),
|
||||
(ModuleStoreEnum.Type.mongo, 'chapter_1', [], 3),
|
||||
(ModuleStoreEnum.Type.mongo, 'sequential_1', ['chapter_1'], 4),
|
||||
(ModuleStoreEnum.Type.mongo, 'vertical_1', ['chapter_1', 'sequential_1'], 5),
|
||||
(ModuleStoreEnum.Type.mongo, 'html_1', ['chapter_1', 'sequential_2', 'vertical_2'], 6),
|
||||
(ModuleStoreEnum.Type.split, 'course', [], 3),
|
||||
(ModuleStoreEnum.Type.split, 'chapter_1', [], 2),
|
||||
(ModuleStoreEnum.Type.split, 'sequential_1', ['chapter_1'], 2),
|
||||
(ModuleStoreEnum.Type.split, 'vertical_1', ['chapter_1', 'sequential_1'], 2),
|
||||
(ModuleStoreEnum.Type.split, 'html_1', ['chapter_1', 'sequential_2', 'vertical_2'], 2),
|
||||
)
|
||||
@ddt.unpack
|
||||
def test_path_and_queries_on_create(self, store_type, block_to_bookmark, ancestors_attrs, expected_mongo_calls):
|
||||
"""
|
||||
In case of mongo, 1 query is used to fetch the block, and 2 by path_to_location(), and then
|
||||
1 query per parent in path is needed to fetch the parent blocks.
|
||||
"""
|
||||
|
||||
self.setup_test_data(store_type)
|
||||
user = UserFactory.create()
|
||||
|
||||
expected_path = [PathItem(
|
||||
usage_key=getattr(self, ancestor_attr).location, display_name=getattr(self, ancestor_attr).display_name
|
||||
) for ancestor_attr in ancestors_attrs]
|
||||
|
||||
bookmark_data = self.get_bookmark_data(getattr(self, block_to_bookmark), user=user)
|
||||
|
||||
with check_mongo_calls(expected_mongo_calls):
|
||||
bookmark, __ = Bookmark.create(bookmark_data)
|
||||
|
||||
self.assertEqual(bookmark.path, expected_path)
|
||||
self.assertIsNotNone(bookmark.xblock_cache)
|
||||
self.assertEqual(bookmark.xblock_cache.paths, [])
|
||||
|
||||
def test_create_bookmark_success(self):
|
||||
"""
|
||||
Tests creation of bookmark.
|
||||
"""
|
||||
bookmark_data = self.get_bookmark_data(self.vertical_2)
|
||||
bookmark, __ = Bookmark.create(bookmark_data)
|
||||
self.assert_bookmark_model_is_valid(bookmark, bookmark_data)
|
||||
|
||||
bookmark_data_different_values = self.get_bookmark_data(self.vertical_2)
|
||||
bookmark_data_different_values['display_name'] = 'Introduction Video'
|
||||
bookmark2, __ = Bookmark.create(bookmark_data_different_values)
|
||||
# The bookmark object already created should have been returned without modifications.
|
||||
self.assertEqual(bookmark, bookmark2)
|
||||
self.assertEqual(bookmark.xblock_cache, bookmark2.xblock_cache)
|
||||
self.assert_bookmark_model_is_valid(bookmark2, bookmark_data)
|
||||
|
||||
bookmark_data_different_user = self.get_bookmark_data(self.vertical_2)
|
||||
bookmark_data_different_user['user'] = UserFactory.create()
|
||||
bookmark3, __ = Bookmark.create(bookmark_data_different_user)
|
||||
self.assertNotEqual(bookmark, bookmark3)
|
||||
self.assert_bookmark_model_is_valid(bookmark3, bookmark_data_different_user)
|
||||
|
||||
@ddt.data(
|
||||
(-30, [[PathItem(EXAMPLE_USAGE_KEY_1, '1')]], 1),
|
||||
(30, None, 2),
|
||||
(30, [], 2),
|
||||
(30, [[PathItem(EXAMPLE_USAGE_KEY_1, '1')]], 1),
|
||||
(30, [[PathItem(EXAMPLE_USAGE_KEY_1, '1')], [PathItem(EXAMPLE_USAGE_KEY_2, '2')]], 2),
|
||||
)
|
||||
@ddt.unpack
|
||||
@mock.patch('openedx.core.djangoapps.bookmarks.models.Bookmark.get_path')
|
||||
def test_path(self, seconds_delta, paths, get_path_call_count, mock_get_path):
|
||||
|
||||
block_path = [PathItem(UsageKey.from_string(EXAMPLE_USAGE_KEY_1), '1')]
|
||||
mock_get_path.return_value = block_path
|
||||
|
||||
html = ItemFactory.create(
|
||||
parent_location=self.other_chapter_1.location, category='html', display_name='Other Lesson 1'
|
||||
)
|
||||
|
||||
bookmark_data = self.get_bookmark_data(html)
|
||||
bookmark, __ = Bookmark.create(bookmark_data)
|
||||
self.assertIsNotNone(bookmark.xblock_cache)
|
||||
|
||||
modification_datetime = datetime.datetime.now(pytz.utc) + datetime.timedelta(seconds=seconds_delta)
|
||||
with freeze_time(modification_datetime):
|
||||
bookmark.xblock_cache.paths = paths
|
||||
bookmark.xblock_cache.save()
|
||||
|
||||
self.assertEqual(bookmark.path, block_path)
|
||||
self.assertEqual(mock_get_path.call_count, get_path_call_count)
|
||||
|
||||
@ddt.data(
|
||||
(ModuleStoreEnum.Type.mongo, 2, 2, 2),
|
||||
(ModuleStoreEnum.Type.mongo, 4, 2, 2),
|
||||
(ModuleStoreEnum.Type.mongo, 6, 2, 2),
|
||||
(ModuleStoreEnum.Type.mongo, 2, 3, 3),
|
||||
(ModuleStoreEnum.Type.mongo, 4, 3, 3),
|
||||
# (ModuleStoreEnum.Type.mongo, 6, 3, 3), Too slow.
|
||||
(ModuleStoreEnum.Type.mongo, 2, 4, 4),
|
||||
# (ModuleStoreEnum.Type.mongo, 4, 4, 4),
|
||||
(ModuleStoreEnum.Type.split, 2, 2, 2),
|
||||
(ModuleStoreEnum.Type.split, 4, 2, 2),
|
||||
(ModuleStoreEnum.Type.split, 2, 3, 2),
|
||||
# (ModuleStoreEnum.Type.split, 4, 3, 2),
|
||||
(ModuleStoreEnum.Type.split, 2, 4, 2),
|
||||
)
|
||||
@ddt.unpack
|
||||
def test_get_path_queries(self, store_type, children_per_block, depth, expected_mongo_calls):
|
||||
"""
|
||||
In case of mongo, 2 queries are used by path_to_location(), and then
|
||||
1 query per parent in path is needed to fetch the parent blocks.
|
||||
"""
|
||||
|
||||
course = self.create_course_with_blocks(children_per_block, depth, store_type)
|
||||
|
||||
# Find a leaf block.
|
||||
block = modulestore().get_course(course.id, depth=None)
|
||||
for __ in range(depth - 1):
|
||||
children = block.get_children()
|
||||
block = children[-1]
|
||||
|
||||
with check_mongo_calls(expected_mongo_calls):
|
||||
path = Bookmark.get_path(block.location)
|
||||
self.assertEqual(len(path), depth - 2)
|
||||
|
||||
def test_get_path_in_case_of_exceptions(self):
|
||||
|
||||
user = UserFactory.create()
|
||||
|
||||
# Block does not exist
|
||||
usage_key = UsageKey.from_string('i4x://edX/apis/html/interactive')
|
||||
usage_key.replace(course_key=self.course.id)
|
||||
self.assertEqual(Bookmark.get_path(usage_key), [])
|
||||
|
||||
# Block is an orphan
|
||||
self.other_sequential_1.children = []
|
||||
modulestore().update_item(self.other_sequential_1, self.admin.id) # pylint: disable=no-member
|
||||
|
||||
bookmark_data = self.get_bookmark_data(self.other_vertical_2, user=user)
|
||||
bookmark, __ = Bookmark.create(bookmark_data)
|
||||
|
||||
self.assertEqual(bookmark.path, [])
|
||||
self.assertIsNotNone(bookmark.xblock_cache)
|
||||
self.assertEqual(bookmark.xblock_cache.paths, [])
|
||||
|
||||
# Parent block could not be retrieved
|
||||
with mock.patch('openedx.core.djangoapps.bookmarks.models.search.path_to_location') as mock_path_to_location:
|
||||
mock_path_to_location.return_value = [usage_key]
|
||||
bookmark_data = self.get_bookmark_data(self.other_sequential_1, user=user)
|
||||
bookmark, __ = Bookmark.create(bookmark_data)
|
||||
self.assertEqual(bookmark.path, [])
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
class XBlockCacheModelTest(ModuleStoreTestCase):
|
||||
"""
|
||||
Test the XBlockCache model.
|
||||
"""
|
||||
|
||||
COURSE_KEY = CourseLocator(org='test', course='test', run='test')
|
||||
CHAPTER1_USAGE_KEY = BlockUsageLocator(COURSE_KEY, block_type='chapter', block_id='chapter1')
|
||||
SECTION1_USAGE_KEY = BlockUsageLocator(COURSE_KEY, block_type='section', block_id='section1')
|
||||
SECTION2_USAGE_KEY = BlockUsageLocator(COURSE_KEY, block_type='section', block_id='section1')
|
||||
VERTICAL1_USAGE_KEY = BlockUsageLocator(COURSE_KEY, block_type='vertical', block_id='sequential1')
|
||||
PATH1 = [
|
||||
[unicode(CHAPTER1_USAGE_KEY), 'Chapter 1'],
|
||||
[unicode(SECTION1_USAGE_KEY), 'Section 1'],
|
||||
]
|
||||
PATH2 = [
|
||||
[unicode(CHAPTER1_USAGE_KEY), 'Chapter 1'],
|
||||
[unicode(SECTION2_USAGE_KEY), 'Section 2'],
|
||||
]
|
||||
|
||||
def setUp(self):
|
||||
super(XBlockCacheModelTest, self).setUp()
|
||||
|
||||
def assert_xblock_cache_data(self, xblock_cache, data):
|
||||
"""
|
||||
Assert that the XBlockCache object values match.
|
||||
"""
|
||||
self.assertEqual(xblock_cache.usage_key, data['usage_key'])
|
||||
self.assertEqual(xblock_cache.course_key, data['usage_key'].course_key)
|
||||
self.assertEqual(xblock_cache.display_name, data['display_name'])
|
||||
self.assertEqual(xblock_cache._paths, data['_paths']) # pylint: disable=protected-access
|
||||
self.assertEqual(xblock_cache.paths, [parse_path_data(path) for path in data['_paths']])
|
||||
|
||||
@ddt.data(
|
||||
(
|
||||
[
|
||||
{'usage_key': VERTICAL1_USAGE_KEY, },
|
||||
{'display_name': '', '_paths': [], },
|
||||
],
|
||||
[
|
||||
{'usage_key': VERTICAL1_USAGE_KEY, 'display_name': 'Vertical 5', '_paths': [PATH2]},
|
||||
{'_paths': []},
|
||||
],
|
||||
),
|
||||
(
|
||||
[
|
||||
{'usage_key': VERTICAL1_USAGE_KEY, 'display_name': 'Vertical 4', '_paths': [PATH1]},
|
||||
{},
|
||||
],
|
||||
[
|
||||
{'usage_key': VERTICAL1_USAGE_KEY, 'display_name': 'Vertical 5', '_paths': [PATH2]},
|
||||
{'_paths': [PATH1]},
|
||||
],
|
||||
),
|
||||
)
|
||||
def test_create(self, data):
|
||||
"""
|
||||
Test XBlockCache.create() constructs and updates objects correctly.
|
||||
"""
|
||||
for create_data, additional_data_to_expect in data:
|
||||
xblock_cache = XBlockCache.create(create_data)
|
||||
create_data.update(additional_data_to_expect)
|
||||
self.assert_xblock_cache_data(xblock_cache, create_data)
|
||||
|
||||
@ddt.data(
|
||||
([], [PATH1]),
|
||||
([PATH1, PATH2], [PATH1]),
|
||||
([PATH1], []),
|
||||
)
|
||||
@ddt.unpack
|
||||
def test_paths(self, original_paths, updated_paths):
|
||||
xblock_cache = XBlockCache.create({
|
||||
'usage_key': self.VERTICAL1_USAGE_KEY,
|
||||
'display_name': 'The end.',
|
||||
'_paths': original_paths,
|
||||
})
|
||||
self.assertEqual(xblock_cache.paths, [parse_path_data(path) for path in original_paths])
|
||||
|
||||
xblock_cache.paths = [parse_path_data(path) for path in updated_paths]
|
||||
xblock_cache.save()
|
||||
|
||||
xblock_cache = XBlockCache.objects.get(id=xblock_cache.id)
|
||||
self.assertEqual(xblock_cache._paths, updated_paths) # pylint: disable=protected-access
|
||||
self.assertEqual(xblock_cache.paths, [parse_path_data(path) for path in updated_paths])
|
||||
84
openedx/core/djangoapps/bookmarks/tests/test_services.py
Normal file
84
openedx/core/djangoapps/bookmarks/tests/test_services.py
Normal file
@@ -0,0 +1,84 @@
|
||||
"""
|
||||
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))
|
||||
166
openedx/core/djangoapps/bookmarks/tests/test_tasks.py
Normal file
166
openedx/core/djangoapps/bookmarks/tests/test_tasks.py
Normal file
@@ -0,0 +1,166 @@
|
||||
"""
|
||||
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)
|
||||
566
openedx/core/djangoapps/bookmarks/tests/test_views.py
Normal file
566
openedx/core/djangoapps/bookmarks/tests/test_views.py
Normal file
@@ -0,0 +1,566 @@
|
||||
"""
|
||||
Tests for bookmark views.
|
||||
"""
|
||||
|
||||
import ddt
|
||||
import json
|
||||
from unittest import skipUnless
|
||||
import urllib
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.urlresolvers import reverse
|
||||
from mock import patch
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from xmodule.modulestore import ModuleStoreEnum
|
||||
|
||||
from .test_models import BookmarksTestsBase
|
||||
from .test_api import BookmarkApiEventTestMixin
|
||||
|
||||
|
||||
# pylint: disable=no-member
|
||||
class BookmarksViewsTestsBase(BookmarksTestsBase, BookmarkApiEventTestMixin):
|
||||
"""
|
||||
Base class for bookmarks views tests.
|
||||
"""
|
||||
def setUp(self):
|
||||
super(BookmarksViewsTestsBase, self).setUp()
|
||||
|
||||
self.anonymous_client = APIClient()
|
||||
self.client = self.login_client(user=self.user)
|
||||
|
||||
def login_client(self, user):
|
||||
"""
|
||||
Helper method for getting the client and user and logging in. Returns client.
|
||||
"""
|
||||
client = APIClient()
|
||||
client.login(username=user.username, password=self.TEST_PASSWORD)
|
||||
return client
|
||||
|
||||
def send_get(self, client, url, query_parameters=None, expected_status=200):
|
||||
"""
|
||||
Helper method for sending a GET to the server. Verifies the expected status and returns the response.
|
||||
"""
|
||||
url = url + '?' + query_parameters if query_parameters else url
|
||||
response = client.get(url)
|
||||
self.assertEqual(expected_status, response.status_code)
|
||||
return response
|
||||
|
||||
def send_post(self, client, url, data, content_type='application/json', expected_status=201):
|
||||
"""
|
||||
Helper method for sending a POST to the server. Verifies the expected status and returns the response.
|
||||
"""
|
||||
response = client.post(url, data=json.dumps(data), content_type=content_type)
|
||||
self.assertEqual(expected_status, response.status_code)
|
||||
return response
|
||||
|
||||
def send_delete(self, client, url, expected_status=204):
|
||||
"""
|
||||
Helper method for sending a DELETE to the server. Verifies the expected status and returns the response.
|
||||
"""
|
||||
response = client.delete(url)
|
||||
self.assertEqual(expected_status, response.status_code)
|
||||
return response
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
@skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Tests only valid in LMS')
|
||||
class BookmarksListViewTests(BookmarksViewsTestsBase):
|
||||
"""
|
||||
This contains the tests for GET & POST methods of bookmark.views.BookmarksListView class
|
||||
GET /api/bookmarks/v1/bookmarks/?course_id={course_id1}
|
||||
POST /api/bookmarks/v1/bookmarks
|
||||
"""
|
||||
@ddt.data(
|
||||
(1, False),
|
||||
(10, False),
|
||||
(25, False),
|
||||
(1, True),
|
||||
(10, True),
|
||||
(25, True),
|
||||
)
|
||||
@ddt.unpack
|
||||
@patch('eventtracking.tracker.emit')
|
||||
def test_get_bookmarks_successfully(self, bookmarks_count, check_all_fields, mock_tracker):
|
||||
"""
|
||||
Test that requesting bookmarks for a course returns records successfully in
|
||||
expected order without optional fields.
|
||||
"""
|
||||
|
||||
course, __, bookmarks = self.create_course_with_bookmarks_count(
|
||||
bookmarks_count, store_type=ModuleStoreEnum.Type.mongo
|
||||
)
|
||||
|
||||
query_parameters = 'course_id={}&page_size={}'.format(urllib.quote(unicode(course.id)), 100)
|
||||
if check_all_fields:
|
||||
query_parameters += '&fields=path,display_name'
|
||||
|
||||
with self.assertNumQueries(9): # 2 queries for bookmark table.
|
||||
response = self.send_get(
|
||||
client=self.client,
|
||||
url=reverse('bookmarks'),
|
||||
query_parameters=query_parameters,
|
||||
)
|
||||
|
||||
bookmarks_data = response.data['results']
|
||||
|
||||
self.assertEqual(len(bookmarks_data), len(bookmarks))
|
||||
self.assertEqual(response.data['count'], len(bookmarks))
|
||||
self.assertEqual(response.data['num_pages'], 1)
|
||||
|
||||
# As bookmarks are sorted by -created so we will compare in that order.
|
||||
self.assert_bookmark_data_is_valid(bookmarks[-1], bookmarks_data[0], check_optional_fields=check_all_fields)
|
||||
self.assert_bookmark_data_is_valid(bookmarks[0], bookmarks_data[-1], check_optional_fields=check_all_fields)
|
||||
|
||||
self.assert_bookmark_event_emitted(
|
||||
mock_tracker,
|
||||
event_name='edx.bookmark.listed',
|
||||
course_id=unicode(course.id),
|
||||
list_type='per_course',
|
||||
bookmarks_count=bookmarks_count,
|
||||
page_size=100,
|
||||
page_number=1
|
||||
)
|
||||
|
||||
@ddt.data(
|
||||
10, 25
|
||||
)
|
||||
@patch('eventtracking.tracker.emit')
|
||||
def test_get_bookmarks_with_pagination(self, bookmarks_count, mock_tracker):
|
||||
"""
|
||||
Test that requesting bookmarks for a course return results with pagination 200 code.
|
||||
"""
|
||||
|
||||
course, __, bookmarks = self.create_course_with_bookmarks_count(
|
||||
bookmarks_count, store_type=ModuleStoreEnum.Type.mongo
|
||||
)
|
||||
|
||||
page_size = 5
|
||||
query_parameters = 'course_id={}&page_size={}'.format(urllib.quote(unicode(course.id)), page_size)
|
||||
|
||||
with self.assertNumQueries(9): # 2 queries for bookmark table.
|
||||
response = self.send_get(
|
||||
client=self.client,
|
||||
url=reverse('bookmarks'),
|
||||
query_parameters=query_parameters
|
||||
)
|
||||
|
||||
bookmarks_data = response.data['results']
|
||||
|
||||
# Pagination assertions.
|
||||
self.assertEqual(response.data['count'], bookmarks_count)
|
||||
self.assertIn('page=2&page_size={}'.format(page_size), response.data['next'])
|
||||
self.assertEqual(response.data['num_pages'], bookmarks_count / page_size)
|
||||
|
||||
self.assertEqual(len(bookmarks_data), min(bookmarks_count, page_size))
|
||||
self.assert_bookmark_data_is_valid(bookmarks[-1], bookmarks_data[0])
|
||||
|
||||
self.assert_bookmark_event_emitted(
|
||||
mock_tracker,
|
||||
event_name='edx.bookmark.listed',
|
||||
course_id=unicode(course.id),
|
||||
list_type='per_course',
|
||||
bookmarks_count=bookmarks_count,
|
||||
page_size=page_size,
|
||||
page_number=1
|
||||
)
|
||||
|
||||
@patch('eventtracking.tracker.emit')
|
||||
def test_get_bookmarks_with_invalid_data(self, mock_tracker):
|
||||
"""
|
||||
Test that requesting bookmarks with invalid data returns 0 records.
|
||||
"""
|
||||
# Invalid course id.
|
||||
with self.assertNumQueries(7): # No queries for bookmark table.
|
||||
response = self.send_get(
|
||||
client=self.client,
|
||||
url=reverse('bookmarks'),
|
||||
query_parameters='course_id=invalid'
|
||||
)
|
||||
bookmarks_data = response.data['results']
|
||||
self.assertEqual(len(bookmarks_data), 0)
|
||||
|
||||
self.assertFalse(mock_tracker.emit.called) # pylint: disable=maybe-no-member
|
||||
|
||||
@patch('eventtracking.tracker.emit')
|
||||
def test_get_all_bookmarks_when_course_id_not_given(self, mock_tracker):
|
||||
"""
|
||||
Test that requesting bookmarks returns all records for that user.
|
||||
"""
|
||||
# Without course id we would return all the bookmarks for that user.
|
||||
|
||||
with self.assertNumQueries(9): # 2 queries for bookmark table.
|
||||
response = self.send_get(
|
||||
client=self.client,
|
||||
url=reverse('bookmarks')
|
||||
)
|
||||
bookmarks_data = response.data['results']
|
||||
self.assertEqual(len(bookmarks_data), 3)
|
||||
self.assert_bookmark_data_is_valid(self.other_bookmark_1, bookmarks_data[0])
|
||||
self.assert_bookmark_data_is_valid(self.bookmark_2, bookmarks_data[1])
|
||||
self.assert_bookmark_data_is_valid(self.bookmark_1, bookmarks_data[2])
|
||||
|
||||
self.assert_bookmark_event_emitted(
|
||||
mock_tracker,
|
||||
event_name='edx.bookmark.listed',
|
||||
list_type='all_courses',
|
||||
bookmarks_count=3,
|
||||
page_size=10,
|
||||
page_number=1
|
||||
)
|
||||
|
||||
def test_anonymous_access(self):
|
||||
"""
|
||||
Test that an anonymous client (not logged in) cannot call GET or POST.
|
||||
"""
|
||||
query_parameters = 'course_id={}'.format(self.course_id)
|
||||
with self.assertNumQueries(4): # No queries for bookmark table.
|
||||
self.send_get(
|
||||
client=self.anonymous_client,
|
||||
url=reverse('bookmarks'),
|
||||
query_parameters=query_parameters,
|
||||
expected_status=401
|
||||
)
|
||||
|
||||
with self.assertNumQueries(4): # No queries for bookmark table.
|
||||
self.send_post(
|
||||
client=self.anonymous_client,
|
||||
url=reverse('bookmarks'),
|
||||
data={'usage_id': 'test'},
|
||||
expected_status=401
|
||||
)
|
||||
|
||||
def test_post_bookmark_successfully(self):
|
||||
"""
|
||||
Test that posting a bookmark successfully returns newly created data with 201 code.
|
||||
"""
|
||||
with self.assertNumQueries(16):
|
||||
response = self.send_post(
|
||||
client=self.client,
|
||||
url=reverse('bookmarks'),
|
||||
data={'usage_id': unicode(self.vertical_3.location)}
|
||||
)
|
||||
|
||||
# Assert Newly created bookmark.
|
||||
self.assertEqual(response.data['id'], '%s,%s' % (self.user.username, unicode(self.vertical_3.location)))
|
||||
self.assertEqual(response.data['course_id'], self.course_id)
|
||||
self.assertEqual(response.data['usage_id'], unicode(self.vertical_3.location))
|
||||
self.assertIsNotNone(response.data['created'])
|
||||
self.assertEqual(len(response.data['path']), 2)
|
||||
self.assertEqual(response.data['display_name'], self.vertical_3.display_name)
|
||||
|
||||
def test_post_bookmark_with_invalid_data(self):
|
||||
"""
|
||||
Test that posting a bookmark for a block with invalid usage id returns a 400.
|
||||
Scenarios:
|
||||
1) Invalid usage id.
|
||||
2) Without usage id.
|
||||
3) With empty request.data
|
||||
"""
|
||||
# Send usage_id with invalid format.
|
||||
with self.assertNumQueries(7): # No queries for bookmark table.
|
||||
response = self.send_post(
|
||||
client=self.client,
|
||||
url=reverse('bookmarks'),
|
||||
data={'usage_id': 'invalid'},
|
||||
expected_status=400
|
||||
)
|
||||
self.assertEqual(response.data['user_message'], u'An error has occurred. Please try again.')
|
||||
|
||||
# Send data without usage_id.
|
||||
with self.assertNumQueries(7): # No queries for bookmark table.
|
||||
response = self.send_post(
|
||||
client=self.client,
|
||||
url=reverse('bookmarks'),
|
||||
data={'course_id': 'invalid'},
|
||||
expected_status=400
|
||||
)
|
||||
self.assertEqual(response.data['user_message'], u'An error has occurred. Please try again.')
|
||||
self.assertEqual(response.data['developer_message'], u'Parameter usage_id not provided.')
|
||||
|
||||
# Send empty data dictionary.
|
||||
with self.assertNumQueries(7): # No queries for bookmark table.
|
||||
response = self.send_post(
|
||||
client=self.client,
|
||||
url=reverse('bookmarks'),
|
||||
data={},
|
||||
expected_status=400
|
||||
)
|
||||
self.assertEqual(response.data['user_message'], u'An error has occurred. Please try again.')
|
||||
self.assertEqual(response.data['developer_message'], u'No data provided.')
|
||||
|
||||
def test_post_bookmark_for_non_existing_block(self):
|
||||
"""
|
||||
Test that posting a bookmark for a block that does not exist returns a 400.
|
||||
"""
|
||||
with self.assertNumQueries(7): # No queries for bookmark table.
|
||||
response = self.send_post(
|
||||
client=self.client,
|
||||
url=reverse('bookmarks'),
|
||||
data={'usage_id': 'i4x://arbi/100/html/340ef1771a094090ad260ec940d04a21'},
|
||||
expected_status=400
|
||||
)
|
||||
self.assertEqual(
|
||||
response.data['user_message'],
|
||||
u'An error has occurred. Please try again.'
|
||||
)
|
||||
self.assertEqual(
|
||||
response.data['developer_message'],
|
||||
u'Block with usage_id: i4x://arbi/100/html/340ef1771a094090ad260ec940d04a21 not found.'
|
||||
)
|
||||
|
||||
@patch('django.conf.settings.MAX_BOOKMARKS_PER_COURSE', 5)
|
||||
def test_post_bookmark_when_max_bookmarks_already_exist(self):
|
||||
"""
|
||||
Test that posting a bookmark for a block that does not exist returns a 400.
|
||||
"""
|
||||
max_bookmarks = settings.MAX_BOOKMARKS_PER_COURSE
|
||||
__, blocks, __ = self.create_course_with_bookmarks_count(max_bookmarks)
|
||||
|
||||
with self.assertNumQueries(8): # No queries for bookmark table.
|
||||
response = self.send_post(
|
||||
client=self.client,
|
||||
url=reverse('bookmarks'),
|
||||
data={'usage_id': unicode(blocks[-1].location)},
|
||||
expected_status=400
|
||||
)
|
||||
self.assertEqual(
|
||||
response.data['user_message'],
|
||||
u'You can create up to {0} bookmarks.'
|
||||
u' You must remove some bookmarks before you can add new ones.'.format(max_bookmarks)
|
||||
)
|
||||
self.assertEqual(
|
||||
response.data['developer_message'],
|
||||
u'You can create up to {0} bookmarks.'
|
||||
u' You must remove some bookmarks before you can add new ones.'.format(max_bookmarks)
|
||||
)
|
||||
|
||||
def test_unsupported_methods(self):
|
||||
"""
|
||||
Test that DELETE and PUT are not supported.
|
||||
"""
|
||||
self.client.login(username=self.user.username, password=self.TEST_PASSWORD)
|
||||
self.assertEqual(405, self.client.put(reverse('bookmarks')).status_code)
|
||||
self.assertEqual(405, self.client.delete(reverse('bookmarks')).status_code)
|
||||
|
||||
@patch('eventtracking.tracker.emit')
|
||||
@ddt.unpack
|
||||
@ddt.data(
|
||||
{'page_size': -1, 'expected_bookmarks_count': 2, 'expected_page_size': 10, 'expected_page_number': 1},
|
||||
{'page_size': 0, 'expected_bookmarks_count': 2, 'expected_page_size': 10, 'expected_page_number': 1},
|
||||
{'page_size': 999, 'expected_bookmarks_count': 2, 'expected_page_size': 100, 'expected_page_number': 1}
|
||||
)
|
||||
def test_listed_event_for_different_page_size_values(self, mock_tracker, page_size, expected_bookmarks_count,
|
||||
expected_page_size, expected_page_number):
|
||||
""" Test that edx.course.bookmark.listed event values are as expected for different page size values """
|
||||
query_parameters = 'course_id={}&page_size={}'.format(urllib.quote(self.course_id), page_size)
|
||||
|
||||
self.send_get(client=self.client, url=reverse('bookmarks'), query_parameters=query_parameters)
|
||||
|
||||
self.assert_bookmark_event_emitted(
|
||||
mock_tracker,
|
||||
event_name='edx.bookmark.listed',
|
||||
course_id=self.course_id,
|
||||
list_type='per_course',
|
||||
bookmarks_count=expected_bookmarks_count,
|
||||
page_size=expected_page_size,
|
||||
page_number=expected_page_number
|
||||
)
|
||||
|
||||
@patch('openedx.core.djangoapps.bookmarks.views.eventtracking.tracker.emit')
|
||||
def test_listed_event_for_page_number(self, mock_tracker):
|
||||
""" Test that edx.course.bookmark.listed event values are as expected when we request a specific page number """
|
||||
self.send_get(client=self.client, url=reverse('bookmarks'), query_parameters='page_size=2&page=2')
|
||||
|
||||
self.assert_bookmark_event_emitted(
|
||||
mock_tracker,
|
||||
event_name='edx.bookmark.listed',
|
||||
list_type='all_courses',
|
||||
bookmarks_count=3,
|
||||
page_size=2,
|
||||
page_number=2
|
||||
)
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
@skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Tests only valid in LMS')
|
||||
class BookmarksDetailViewTests(BookmarksViewsTestsBase):
|
||||
"""
|
||||
This contains the tests for GET & DELETE methods of bookmark.views.BookmarksDetailView class
|
||||
"""
|
||||
@ddt.data(
|
||||
('', False),
|
||||
('fields=path,display_name', True)
|
||||
)
|
||||
@ddt.unpack
|
||||
def test_get_bookmark_successfully(self, query_params, check_optional_fields):
|
||||
"""
|
||||
Test that requesting bookmark returns data with 200 code.
|
||||
"""
|
||||
with self.assertNumQueries(8): # 1 query for bookmark table.
|
||||
response = self.send_get(
|
||||
client=self.client,
|
||||
url=reverse(
|
||||
'bookmarks_detail',
|
||||
kwargs={'username': self.user.username, 'usage_id': unicode(self.sequential_1.location)}
|
||||
),
|
||||
query_parameters=query_params
|
||||
)
|
||||
data = response.data
|
||||
self.assertIsNotNone(data)
|
||||
self.assert_bookmark_data_is_valid(self.bookmark_1, data, check_optional_fields=check_optional_fields)
|
||||
|
||||
def test_get_bookmark_that_belongs_to_other_user(self):
|
||||
"""
|
||||
Test that requesting bookmark that belongs to other user returns 404 status code.
|
||||
"""
|
||||
with self.assertNumQueries(8): # No queries for bookmark table.
|
||||
self.send_get(
|
||||
client=self.client,
|
||||
url=reverse(
|
||||
'bookmarks_detail',
|
||||
kwargs={'username': 'other', 'usage_id': unicode(self.vertical_1.location)}
|
||||
),
|
||||
expected_status=404
|
||||
)
|
||||
|
||||
def test_get_bookmark_that_does_not_exist(self):
|
||||
"""
|
||||
Test that requesting bookmark that does not exist returns 404 status code.
|
||||
"""
|
||||
with self.assertNumQueries(8): # 1 query for bookmark table.
|
||||
response = self.send_get(
|
||||
client=self.client,
|
||||
url=reverse(
|
||||
'bookmarks_detail',
|
||||
kwargs={'username': self.user.username, 'usage_id': 'i4x://arbi/100/html/340ef1771a0940'}
|
||||
),
|
||||
expected_status=404
|
||||
)
|
||||
self.assertEqual(
|
||||
response.data['user_message'],
|
||||
'Bookmark with usage_id: i4x://arbi/100/html/340ef1771a0940 does not exist.'
|
||||
)
|
||||
self.assertEqual(
|
||||
response.data['developer_message'],
|
||||
'Bookmark with usage_id: i4x://arbi/100/html/340ef1771a0940 does not exist.'
|
||||
)
|
||||
|
||||
def test_get_bookmark_with_invalid_usage_id(self):
|
||||
"""
|
||||
Test that requesting bookmark with invalid usage id returns 400.
|
||||
"""
|
||||
with self.assertNumQueries(7): # No queries for bookmark table.
|
||||
response = self.send_get(
|
||||
client=self.client,
|
||||
url=reverse(
|
||||
'bookmarks_detail',
|
||||
kwargs={'username': self.user.username, 'usage_id': 'i4x'}
|
||||
),
|
||||
expected_status=404
|
||||
)
|
||||
self.assertEqual(response.data['user_message'], u'Invalid usage_id: i4x.')
|
||||
|
||||
def test_anonymous_access(self):
|
||||
"""
|
||||
Test that an anonymous client (not logged in) cannot call GET or DELETE.
|
||||
"""
|
||||
url = reverse('bookmarks_detail', kwargs={'username': self.user.username, 'usage_id': 'i4x'})
|
||||
with self.assertNumQueries(7): # No queries for bookmark table.
|
||||
self.send_get(
|
||||
client=self.anonymous_client,
|
||||
url=url,
|
||||
expected_status=401
|
||||
)
|
||||
|
||||
with self.assertNumQueries(4):
|
||||
self.send_delete(
|
||||
client=self.anonymous_client,
|
||||
url=url,
|
||||
expected_status=401
|
||||
)
|
||||
|
||||
def test_delete_bookmark_successfully(self):
|
||||
"""
|
||||
Test that delete bookmark returns 204 status code with success.
|
||||
"""
|
||||
query_parameters = 'course_id={}'.format(urllib.quote(self.course_id))
|
||||
response = self.send_get(client=self.client, url=reverse('bookmarks'), query_parameters=query_parameters)
|
||||
bookmarks_data = response.data['results']
|
||||
self.assertEqual(len(bookmarks_data), 2)
|
||||
|
||||
with self.assertNumQueries(10): # 2 queries for bookmark table.
|
||||
self.send_delete(
|
||||
client=self.client,
|
||||
url=reverse(
|
||||
'bookmarks_detail',
|
||||
kwargs={'username': self.user.username, 'usage_id': unicode(self.sequential_1.location)}
|
||||
)
|
||||
)
|
||||
response = self.send_get(client=self.client, url=reverse('bookmarks'), query_parameters=query_parameters)
|
||||
bookmarks_data = response.data['results']
|
||||
|
||||
self.assertEqual(len(bookmarks_data), 1)
|
||||
|
||||
def test_delete_bookmark_that_belongs_to_other_user(self):
|
||||
"""
|
||||
Test that delete bookmark that belongs to other user returns 404.
|
||||
"""
|
||||
with self.assertNumQueries(8): # No queries for bookmark table.
|
||||
self.send_delete(
|
||||
client=self.client,
|
||||
url=reverse(
|
||||
'bookmarks_detail',
|
||||
kwargs={'username': 'other', 'usage_id': unicode(self.vertical_1.location)}
|
||||
),
|
||||
expected_status=404
|
||||
)
|
||||
|
||||
def test_delete_bookmark_that_does_not_exist(self):
|
||||
"""
|
||||
Test that delete bookmark that does not exist returns 404.
|
||||
"""
|
||||
with self.assertNumQueries(8): # 1 query for bookmark table.
|
||||
response = self.send_delete(
|
||||
client=self.client,
|
||||
url=reverse(
|
||||
'bookmarks_detail',
|
||||
kwargs={'username': self.user.username, 'usage_id': 'i4x://arbi/100/html/340ef1771a0940'}
|
||||
),
|
||||
expected_status=404
|
||||
)
|
||||
self.assertEqual(
|
||||
response.data['user_message'],
|
||||
u'Bookmark with usage_id: i4x://arbi/100/html/340ef1771a0940 does not exist.'
|
||||
)
|
||||
self.assertEqual(
|
||||
response.data['developer_message'],
|
||||
'Bookmark with usage_id: i4x://arbi/100/html/340ef1771a0940 does not exist.'
|
||||
)
|
||||
|
||||
def test_delete_bookmark_with_invalid_usage_id(self):
|
||||
"""
|
||||
Test that delete bookmark with invalid usage id returns 400.
|
||||
"""
|
||||
with self.assertNumQueries(7): # No queries for bookmark table.
|
||||
response = self.send_delete(
|
||||
client=self.client,
|
||||
url=reverse(
|
||||
'bookmarks_detail',
|
||||
kwargs={'username': self.user.username, 'usage_id': 'i4x'}
|
||||
),
|
||||
expected_status=404
|
||||
)
|
||||
self.assertEqual(response.data['user_message'], u'Invalid usage_id: i4x.')
|
||||
|
||||
def test_unsupported_methods(self):
|
||||
"""
|
||||
Test that POST and PUT are not supported.
|
||||
"""
|
||||
url = reverse('bookmarks_detail', kwargs={'username': self.user.username, 'usage_id': 'i4x'})
|
||||
self.client.login(username=self.user.username, password=self.TEST_PASSWORD)
|
||||
with self.assertNumQueries(8): # No queries for bookmark table.
|
||||
self.assertEqual(405, self.client.put(url).status_code)
|
||||
|
||||
with self.assertNumQueries(8):
|
||||
self.assertEqual(405, self.client.post(url).status_code)
|
||||
26
openedx/core/djangoapps/bookmarks/urls.py
Normal file
26
openedx/core/djangoapps/bookmarks/urls.py
Normal file
@@ -0,0 +1,26 @@
|
||||
"""
|
||||
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'
|
||||
),
|
||||
)
|
||||
367
openedx/core/djangoapps/bookmarks/views.py
Normal file
367
openedx/core/djangoapps/bookmarks/views.py
Normal file
@@ -0,0 +1,367 @@
|
||||
"""
|
||||
HTTP end-points for the Bookmarks API.
|
||||
|
||||
For more information, see:
|
||||
https://openedx.atlassian.net/wiki/display/TNL/Bookmarks+API
|
||||
"""
|
||||
import eventtracking
|
||||
import logging
|
||||
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from django.utils.translation import ugettext as _, ugettext_noop
|
||||
|
||||
from rest_framework import status
|
||||
from rest_framework import permissions
|
||||
from rest_framework.authentication import SessionAuthentication
|
||||
from rest_framework.generics import ListCreateAPIView
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework_oauth.authentication import OAuth2Authentication
|
||||
|
||||
from opaque_keys import InvalidKeyError
|
||||
from opaque_keys.edx.keys import CourseKey, UsageKey
|
||||
from django.conf import settings
|
||||
from openedx.core.djangoapps.bookmarks.api import BookmarksLimitReachedError
|
||||
|
||||
from openedx.core.lib.api.permissions import IsUserInUrl
|
||||
|
||||
from xmodule.modulestore.exceptions import ItemNotFoundError
|
||||
|
||||
from lms.djangoapps.lms_xblock.runtime import unquote_slashes
|
||||
from openedx.core.lib.api.paginators import DefaultPagination
|
||||
|
||||
from . import DEFAULT_FIELDS, OPTIONAL_FIELDS, api
|
||||
from .serializers import BookmarkSerializer
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# Default error message for user
|
||||
DEFAULT_USER_MESSAGE = ugettext_noop(u'An error has occurred. Please try again.')
|
||||
|
||||
|
||||
class BookmarksPagination(DefaultPagination):
|
||||
"""
|
||||
Paginator for bookmarks API.
|
||||
"""
|
||||
page_size = 10
|
||||
max_page_size = 100
|
||||
|
||||
def get_paginated_response(self, data):
|
||||
"""
|
||||
Annotate the response with pagination information.
|
||||
"""
|
||||
response = super(BookmarksPagination, self).get_paginated_response(data)
|
||||
|
||||
# Add `current_page` value, it's needed for pagination footer.
|
||||
response.data["current_page"] = self.page.number
|
||||
|
||||
# Add `start` value, it's needed for the pagination header.
|
||||
response.data["start"] = (self.page.number - 1) * self.get_page_size(self.request)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
class BookmarksViewMixin(object):
|
||||
"""
|
||||
Shared code for bookmarks views.
|
||||
"""
|
||||
|
||||
def fields_to_return(self, params):
|
||||
"""
|
||||
Returns names of fields which should be included in the response.
|
||||
|
||||
Arguments:
|
||||
params (dict): The request parameters.
|
||||
"""
|
||||
optional_fields = params.get('fields', '').split(',')
|
||||
return DEFAULT_FIELDS + [field for field in optional_fields if field in OPTIONAL_FIELDS]
|
||||
|
||||
def error_response(self, developer_message, user_message=None, error_status=status.HTTP_400_BAD_REQUEST):
|
||||
"""
|
||||
Create and return a Response.
|
||||
|
||||
Arguments:
|
||||
message (string): The message to put in the developer_message
|
||||
and user_message fields.
|
||||
status: The status of the response. Default is HTTP_400_BAD_REQUEST.
|
||||
"""
|
||||
if not user_message:
|
||||
user_message = developer_message
|
||||
|
||||
return Response(
|
||||
{
|
||||
"developer_message": developer_message,
|
||||
"user_message": _(user_message) # pylint: disable=translation-of-non-string
|
||||
},
|
||||
status=error_status
|
||||
)
|
||||
|
||||
|
||||
class BookmarksListView(ListCreateAPIView, BookmarksViewMixin):
|
||||
"""
|
||||
**Use Case**
|
||||
|
||||
* Get a paginated list of bookmarks for a user.
|
||||
|
||||
The list can be filtered by passing parameter "course_id=<course_id>"
|
||||
to only include bookmarks from a particular course.
|
||||
|
||||
The bookmarks are always sorted in descending order by creation date.
|
||||
|
||||
Each page in the list contains 10 bookmarks by default. The page
|
||||
size can be altered by passing parameter "page_size=<page_size>".
|
||||
|
||||
To include the optional fields pass the values in "fields" parameter
|
||||
as a comma separated list. Possible values are:
|
||||
|
||||
* "display_name"
|
||||
* "path"
|
||||
|
||||
* Create a new bookmark for a user.
|
||||
|
||||
The POST request only needs to contain one parameter "usage_id".
|
||||
|
||||
Http400 is returned if the format of the request is not correct,
|
||||
the usage_id is invalid or a block corresponding to the usage_id
|
||||
could not be found.
|
||||
|
||||
**Example Requests**
|
||||
|
||||
GET /api/bookmarks/v1/bookmarks/?course_id={course_id1}&fields=display_name,path
|
||||
|
||||
POST /api/bookmarks/v1/bookmarks/
|
||||
Request data: {"usage_id": <usage-id>}
|
||||
|
||||
**Response Values**
|
||||
|
||||
* count: The number of bookmarks in a course.
|
||||
|
||||
* next: The URI to the next page of bookmarks.
|
||||
|
||||
* previous: The URI to the previous page of bookmarks.
|
||||
|
||||
* num_pages: The number of pages listing bookmarks.
|
||||
|
||||
* results: A list of bookmarks returned. Each collection in the list
|
||||
contains these fields.
|
||||
|
||||
* id: String. The identifier string for the bookmark: {user_id},{usage_id}.
|
||||
|
||||
* course_id: String. The identifier string of the bookmark's course.
|
||||
|
||||
* usage_id: String. The identifier string of the bookmark's XBlock.
|
||||
|
||||
* display_name: String. (optional) Display name of the XBlock.
|
||||
|
||||
* path: List. (optional) List of dicts containing {"usage_id": <usage-id>, display_name:<display-name>}
|
||||
for the XBlocks from the top of the course tree till the parent of the bookmarked XBlock.
|
||||
|
||||
* created: ISO 8601 String. The timestamp of bookmark's creation.
|
||||
|
||||
"""
|
||||
authentication_classes = (OAuth2Authentication, SessionAuthentication)
|
||||
pagination_class = BookmarksPagination
|
||||
permission_classes = (permissions.IsAuthenticated,)
|
||||
serializer_class = BookmarkSerializer
|
||||
|
||||
def get_serializer_context(self):
|
||||
"""
|
||||
Return the context for the serializer.
|
||||
"""
|
||||
context = super(BookmarksListView, self).get_serializer_context()
|
||||
if self.request.method == 'GET':
|
||||
context['fields'] = self.fields_to_return(self.request.query_params)
|
||||
return context
|
||||
|
||||
def get_queryset(self):
|
||||
"""
|
||||
Returns queryset of bookmarks for GET requests.
|
||||
|
||||
The results will only include bookmarks for the request's user.
|
||||
If the course_id is specified in the request parameters,
|
||||
the queryset will only include bookmarks from that course.
|
||||
"""
|
||||
course_id = self.request.query_params.get('course_id', None)
|
||||
|
||||
if course_id:
|
||||
try:
|
||||
course_key = CourseKey.from_string(course_id)
|
||||
except InvalidKeyError:
|
||||
log.error(u'Invalid course_id: %s.', course_id)
|
||||
return []
|
||||
else:
|
||||
course_key = None
|
||||
|
||||
return api.get_bookmarks(
|
||||
user=self.request.user, course_key=course_key,
|
||||
fields=self.fields_to_return(self.request.query_params), serialized=False
|
||||
)
|
||||
|
||||
def paginate_queryset(self, queryset):
|
||||
""" Override GenericAPIView.paginate_queryset for the purpose of eventing """
|
||||
page = super(BookmarksListView, self).paginate_queryset(queryset)
|
||||
|
||||
course_id = self.request.query_params.get('course_id')
|
||||
if course_id:
|
||||
try:
|
||||
CourseKey.from_string(course_id)
|
||||
except InvalidKeyError:
|
||||
return page
|
||||
|
||||
event_data = {
|
||||
'list_type': 'all_courses',
|
||||
'bookmarks_count': self.paginator.page.paginator.count,
|
||||
'page_size': self.paginator.page.paginator.per_page,
|
||||
'page_number': self.paginator.page.number,
|
||||
}
|
||||
if course_id is not None:
|
||||
event_data['list_type'] = 'per_course'
|
||||
event_data['course_id'] = course_id
|
||||
|
||||
eventtracking.tracker.emit('edx.bookmark.listed', event_data)
|
||||
|
||||
return page
|
||||
|
||||
def post(self, request):
|
||||
"""
|
||||
POST /api/bookmarks/v1/bookmarks/
|
||||
Request data: {"usage_id": "<usage-id>"}
|
||||
"""
|
||||
|
||||
if not request.data:
|
||||
return self.error_response(ugettext_noop(u'No data provided.'), DEFAULT_USER_MESSAGE)
|
||||
|
||||
usage_id = request.data.get('usage_id', None)
|
||||
if not usage_id:
|
||||
return self.error_response(ugettext_noop(u'Parameter usage_id not provided.'), DEFAULT_USER_MESSAGE)
|
||||
|
||||
try:
|
||||
usage_key = UsageKey.from_string(unquote_slashes(usage_id))
|
||||
except InvalidKeyError:
|
||||
error_message = ugettext_noop(u'Invalid usage_id: {usage_id}.').format(usage_id=usage_id)
|
||||
log.error(error_message)
|
||||
return self.error_response(error_message, DEFAULT_USER_MESSAGE)
|
||||
|
||||
try:
|
||||
bookmark = api.create_bookmark(user=self.request.user, usage_key=usage_key)
|
||||
except ItemNotFoundError:
|
||||
error_message = ugettext_noop(u'Block with usage_id: {usage_id} not found.').format(usage_id=usage_id)
|
||||
log.error(error_message)
|
||||
return self.error_response(error_message, DEFAULT_USER_MESSAGE)
|
||||
except BookmarksLimitReachedError:
|
||||
error_message = ugettext_noop(
|
||||
u'You can create up to {max_num_bookmarks_per_course} bookmarks.'
|
||||
u' You must remove some bookmarks before you can add new ones.'
|
||||
).format(max_num_bookmarks_per_course=settings.MAX_BOOKMARKS_PER_COURSE)
|
||||
log.info(
|
||||
u'Attempted to create more than %s bookmarks',
|
||||
settings.MAX_BOOKMARKS_PER_COURSE
|
||||
)
|
||||
return self.error_response(error_message)
|
||||
|
||||
return Response(bookmark, status=status.HTTP_201_CREATED)
|
||||
|
||||
|
||||
class BookmarksDetailView(APIView, BookmarksViewMixin):
|
||||
"""
|
||||
**Use Cases**
|
||||
|
||||
Get or delete a specific bookmark for a user.
|
||||
|
||||
**Example Requests**:
|
||||
|
||||
GET /api/bookmarks/v1/bookmarks/{username},{usage_id}/?fields=display_name,path
|
||||
|
||||
DELETE /api/bookmarks/v1/bookmarks/{username},{usage_id}/
|
||||
|
||||
**Response for GET**
|
||||
|
||||
Users can only delete their own bookmarks. If the bookmark_id does not belong
|
||||
to a requesting user's bookmark a Http404 is returned. Http404 will also be
|
||||
returned if the bookmark does not exist.
|
||||
|
||||
* id: String. The identifier string for the bookmark: {user_id},{usage_id}.
|
||||
|
||||
* course_id: String. The identifier string of the bookmark's course.
|
||||
|
||||
* usage_id: String. The identifier string of the bookmark's XBlock.
|
||||
|
||||
* display_name: (optional) String. Display name of the XBlock.
|
||||
|
||||
* path: (optional) List of dicts containing {"usage_id": <usage-id>, display_name: <display-name>}
|
||||
for the XBlocks from the top of the course tree till the parent of the bookmarked XBlock.
|
||||
|
||||
* created: ISO 8601 String. The timestamp of bookmark's creation.
|
||||
|
||||
**Response for DELETE**
|
||||
|
||||
Users can only delete their own bookmarks.
|
||||
|
||||
A successful delete returns a 204 and no content.
|
||||
|
||||
Users can only delete their own bookmarks. If the bookmark_id does not belong
|
||||
to a requesting user's bookmark a 404 is returned. 404 will also be returned
|
||||
if the bookmark does not exist.
|
||||
"""
|
||||
authentication_classes = (OAuth2Authentication, SessionAuthentication)
|
||||
permission_classes = (permissions.IsAuthenticated, IsUserInUrl)
|
||||
|
||||
serializer_class = BookmarkSerializer
|
||||
|
||||
def get_usage_key_or_error_response(self, usage_id):
|
||||
"""
|
||||
Create and return usage_key or error Response.
|
||||
|
||||
Arguments:
|
||||
usage_id (string): The id of required block.
|
||||
"""
|
||||
try:
|
||||
return UsageKey.from_string(usage_id)
|
||||
except InvalidKeyError:
|
||||
error_message = ugettext_noop(u'Invalid usage_id: {usage_id}.').format(usage_id=usage_id)
|
||||
log.error(error_message)
|
||||
return self.error_response(error_message, error_status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
def get(self, request, username=None, usage_id=None): # pylint: disable=unused-argument
|
||||
"""
|
||||
GET /api/bookmarks/v1/bookmarks/{username},{usage_id}?fields=display_name,path
|
||||
"""
|
||||
usage_key_or_response = self.get_usage_key_or_error_response(usage_id=usage_id)
|
||||
|
||||
if isinstance(usage_key_or_response, Response):
|
||||
return usage_key_or_response
|
||||
|
||||
try:
|
||||
bookmark_data = api.get_bookmark(
|
||||
user=request.user,
|
||||
usage_key=usage_key_or_response,
|
||||
fields=self.fields_to_return(request.query_params)
|
||||
)
|
||||
except ObjectDoesNotExist:
|
||||
error_message = ugettext_noop(
|
||||
u'Bookmark with usage_id: {usage_id} does not exist.'
|
||||
).format(usage_id=usage_id)
|
||||
log.error(error_message)
|
||||
return self.error_response(error_message, error_status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
return Response(bookmark_data)
|
||||
|
||||
def delete(self, request, username=None, usage_id=None): # pylint: disable=unused-argument
|
||||
"""
|
||||
DELETE /api/bookmarks/v1/bookmarks/{username},{usage_id}
|
||||
"""
|
||||
usage_key_or_response = self.get_usage_key_or_error_response(usage_id=usage_id)
|
||||
|
||||
if isinstance(usage_key_or_response, Response):
|
||||
return usage_key_or_response
|
||||
|
||||
try:
|
||||
api.delete_bookmark(user=request.user, usage_key=usage_key_or_response)
|
||||
except ObjectDoesNotExist:
|
||||
error_message = ugettext_noop(
|
||||
u'Bookmark with usage_id: {usage_id} does not exist.'
|
||||
).format(usage_id=usage_id)
|
||||
log.error(error_message)
|
||||
return self.error_response(error_message, error_status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user