Commit 5e972b2a by Usman Khalid

Merge pull request #11022 from edx/bookmarking

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