From a04a635efc4af31ec3b9a3ec663eff93a8b7c961 Mon Sep 17 00:00:00 2001 From: Jonathan Piacenti <kelketek@gmail.com> Date: Thu, 3 Dec 2015 03:56:46 +0000 Subject: [PATCH] Add accomplishments to user profile --- common/static/common/js/components/collections/paging_collection.js | 42 ++++++++++++++++++++++-------------------- common/static/common/js/components/views/list.js | 20 ++++++++++++++------ common/static/common/js/components/views/paginated_view.js | 13 ++++++++++--- common/static/common/js/components/views/paging_footer.js | 4 +++- common/static/common/js/components/views/tabbed_view.js | 138 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ common/static/common/js/spec/components/tabbed_view_spec.js | 138 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ common/static/common/js/spec/main_requirejs.js | 1 + common/static/common/js/spec_helpers/ajax_helpers.js | 4 ++++ common/static/common/templates/components/paging-footer.underscore | 8 ++++++-- common/static/common/templates/components/tab.underscore | 1 + common/static/common/templates/components/tabbed_view.underscore | 4 ++++ common/static/common/templates/components/tabpanel.underscore | 3 +++ lms/djangoapps/badges/api/serializers.py | 4 ++-- lms/djangoapps/badges/api/views.py | 18 ++++++++++++++++-- lms/djangoapps/badges/migrations/0001_initial.py | 8 ++++++-- lms/djangoapps/badges/migrations/0002_data__migrate_assertions.py | 12 ++++++++++-- lms/djangoapps/badges/migrations/0003_schema__add_event_configuration.py | 6 +++--- lms/djangoapps/badges/models.py | 15 ++++++++++----- lms/djangoapps/student_profile/views.py | 15 +++++++++------ lms/djangoapps/teams/static/teams/js/collections/team.js | 4 ++-- lms/djangoapps/teams/static/teams/js/collections/topic.js | 4 ++-- lms/djangoapps/teams/static/teams/js/teams_tab_factory.js | 3 ++- lms/djangoapps/teams/static/teams/js/views/teams.js | 2 ++ lms/djangoapps/teams/static/teams/js/views/teams_tabbed_view.js | 2 +- lms/djangoapps/teams/views.py | 30 ++---------------------------- lms/envs/common.py | 3 +++ lms/static/js/bookmarks/collections/bookmarks.js | 2 +- lms/static/js/components/tabbed/views/tabbed_view.js | 139 ------------------------------------------------------------------------------------------------------------------------------------------- lms/static/js/edxnotes/collections/notes.js | 2 +- lms/static/js/spec/components/tabbed/tabbed_view_spec.js | 137 ----------------------------------------------------------------------------------------------------------------------------------------- lms/static/js/spec/main.js | 1 - lms/static/js/spec/student_account/helpers.js | 7 ++++++- lms/static/js/spec/student_profile/helpers.js | 117 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-- lms/static/js/spec/student_profile/learner_profile_factory_spec.js | 165 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ lms/static/js/spec/student_profile/learner_profile_view_spec.js | 21 +++++++++++++++++++-- lms/static/js/student_account/models/user_account_model.js | 1 + lms/static/js/student_profile/models/badges_model.js | 8 ++++++++ lms/static/js/student_profile/views/badge_list_container.js | 23 +++++++++++++++++++++++ lms/static/js/student_profile/views/badge_list_view.js | 40 ++++++++++++++++++++++++++++++++++++++++ lms/static/js/student_profile/views/badge_view.js | 23 +++++++++++++++++++++++ lms/static/js/student_profile/views/learner_profile_factory.js | 21 ++++++++++++++++++--- lms/static/js/student_profile/views/learner_profile_view.js | 84 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------- lms/static/js/student_profile/views/section_two_tab.js | 30 ++++++++++++++++++++++++++++++ lms/static/js_test.yml | 1 - lms/static/sass/_build-lms.scss | 1 + lms/static/sass/elements/_navigation.scss | 36 ++++++++++++++++++++++++++++++++++++ lms/static/sass/views/_learner-profile.scss | 86 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------- lms/static/sass/views/_teams.scss | 35 +---------------------------------- lms/templates/components/tabbed/tab.underscore | 1 - lms/templates/components/tabbed/tabbed_view.underscore | 4 ---- lms/templates/components/tabbed/tabpanel.underscore | 3 --- lms/templates/student_profile/badge.underscore | 16 ++++++++++++++++ lms/templates/student_profile/badge_list.underscore | 4 ++++ lms/templates/student_profile/badge_placeholder.underscore | 10 ++++++++++ lms/templates/student_profile/learner_profile.underscore | 15 +-------------- lms/templates/student_profile/section_two.underscore | 10 ++++++++++ lms/urls.py | 2 +- openedx/core/djangoapps/user_api/accounts/api.py | 8 +++++--- openedx/core/djangoapps/user_api/accounts/serializers.py | 88 ++++++++++++++++++++++++++++++++++++++++++++++++++-------------------------------------- openedx/core/djangoapps/user_api/accounts/tests/test_api.py | 3 ++- openedx/core/djangoapps/user_api/accounts/tests/test_views.py | 17 +++++++++++------ openedx/core/djangoapps/user_api/accounts/views.py | 10 +++++++--- openedx/core/djangoapps/user_api/permissions.py | 35 +++++++++++++++++++++++++++++++++++ openedx/core/lib/api/paginators.py | 3 +++ 64 files changed, 1209 insertions(+), 502 deletions(-) create mode 100644 common/static/common/js/components/views/tabbed_view.js create mode 100644 common/static/common/js/spec/components/tabbed_view_spec.js create mode 100644 common/static/common/templates/components/tab.underscore create mode 100644 common/static/common/templates/components/tabbed_view.underscore create mode 100644 common/static/common/templates/components/tabpanel.underscore delete mode 100644 lms/static/js/components/tabbed/views/tabbed_view.js delete mode 100644 lms/static/js/spec/components/tabbed/tabbed_view_spec.js create mode 100644 lms/static/js/student_profile/models/badges_model.js create mode 100644 lms/static/js/student_profile/views/badge_list_container.js create mode 100644 lms/static/js/student_profile/views/badge_list_view.js create mode 100644 lms/static/js/student_profile/views/badge_view.js create mode 100644 lms/static/js/student_profile/views/section_two_tab.js delete mode 100644 lms/templates/components/tabbed/tab.underscore delete mode 100644 lms/templates/components/tabbed/tabbed_view.underscore delete mode 100644 lms/templates/components/tabbed/tabpanel.underscore create mode 100644 lms/templates/student_profile/badge.underscore create mode 100644 lms/templates/student_profile/badge_list.underscore create mode 100644 lms/templates/student_profile/badge_placeholder.underscore create mode 100644 lms/templates/student_profile/section_two.underscore create mode 100644 openedx/core/djangoapps/user_api/permissions.py diff --git a/common/static/common/js/components/collections/paging_collection.js b/common/static/common/js/components/collections/paging_collection.js index 0259b56..3acb1e0 100644 --- a/common/static/common/js/components/collections/paging_collection.js +++ b/common/static/common/js/components/collections/paging_collection.js @@ -21,10 +21,32 @@ define(['backbone.paginator'], function (BackbonePaginator) { var PagingCollection = BackbonePaginator.requestPager.extend({ initialize: function () { + var self = this; // These must be initialized in the constructor because otherwise all PagingCollections would point // to the same object references for sortableFields and filterableFields. this.sortableFields = {}; this.filterableFields = {}; + + this.paginator_core = { + type: 'GET', + dataType: 'json', + url: function () { return this.url; } + }; + this.paginator_ui = { + firstPage: function () { return self.isZeroIndexed ? 0 : 1; }, + // Specifies the initial page during collection initialization + currentPage: self.isZeroIndexed ? 0 : 1, + perPage: function () { return self.perPage; } + }; + + this.currentPage = this.paginator_ui.currentPage; + + this.server_api = { + page: function () { return self.currentPage; }, + page_size: function () { return self.perPage; }, + text_search: function () { return self.searchString ? self.searchString : ''; }, + sort_order: function () { return self.sortField; } + }; }, isZeroIndexed: false, @@ -41,26 +63,6 @@ searchString: null, - paginator_core: { - type: 'GET', - dataType: 'json', - url: function () { return this.url; } - }, - - paginator_ui: { - firstPage: function () { return this.isZeroIndexed ? 0 : 1; }, - // Specifies the initial page during collection initialization - currentPage: function () { return this.isZeroIndexed ? 0 : 1; }, - perPage: function () { return this.perPage; } - }, - - server_api: { - page: function () { return this.currentPage; }, - page_size: function () { return this.perPage; }, - text_search: function () { return this.searchString ? this.searchString : ''; }, - sort_order: function () { return this.sortField; } - }, - parse: function (response) { this.totalCount = response.count; this.currentPage = response.current_page; diff --git a/common/static/common/js/components/views/list.js b/common/static/common/js/components/views/list.js index b8c319a..ed3a64f 100644 --- a/common/static/common/js/components/views/list.js +++ b/common/static/common/js/components/views/list.js @@ -24,18 +24,26 @@ this.itemViews = []; }, + renderCollection: function() { + /** + * Render every item in the collection. + * This should push each rendered item to this.itemViews + * to ensure garbage collection works. + */ + this.collection.each(function (model) { + var itemView = new this.itemViewClass({model: model}); + this.$el.append(itemView.render().el); + this.itemViews.push(itemView); + }, this); + }, + render: function () { // Remove old children views _.each(this.itemViews, function (childView) { childView.remove(); }); this.itemViews = []; - // Render the collection - this.collection.each(function (model) { - var itemView = new this.itemViewClass({model: model}); - this.$el.append(itemView.render().el); - this.itemViews.push(itemView); - }, this); + this.renderCollection(); return this; } }); diff --git a/common/static/common/js/components/views/paginated_view.js b/common/static/common/js/components/views/paginated_view.js index 7755a93..4bf085d 100644 --- a/common/static/common/js/components/views/paginated_view.js +++ b/common/static/common/js/components/views/paginated_view.js @@ -26,7 +26,7 @@ ], function (Backbone, _, PagingHeader, PagingFooter, ListView, paginatedViewTemplate) { var PaginatedView = Backbone.View.extend({ initialize: function () { - var ItemListView = ListView.extend({ + var ItemListView = this.listViewClass.extend({ tagName: 'div', className: this.type + '-container', itemViewClass: this.itemViewClass @@ -39,18 +39,25 @@ }, this); }, + listViewClass: ListView, + + viewTemplate: paginatedViewTemplate, + + paginationLabel: gettext("Pagination"), + createHeaderView: function() { return new PagingHeader({collection: this.options.collection, srInfo: this.srInfo}); }, createFooterView: function() { return new PagingFooter({ - collection: this.options.collection, hideWhenOnePage: true + collection: this.options.collection, hideWhenOnePage: true, + paginationLabel: this.paginationLabel }); }, render: function () { - this.$el.html(_.template(paginatedViewTemplate)({type: this.type})); + this.$el.html(_.template(this.viewTemplate)({type: this.type})); this.assign(this.listView, '.' + this.type + '-list'); if (this.headerView) { this.assign(this.headerView, '.' + this.type + '-paging-header'); diff --git a/common/static/common/js/components/views/paging_footer.js b/common/static/common/js/components/views/paging_footer.js index 8b796b5..b68a0e9 100644 --- a/common/static/common/js/components/views/paging_footer.js +++ b/common/static/common/js/components/views/paging_footer.js @@ -13,6 +13,7 @@ initialize: function(options) { this.collection = options.collection; this.hideWhenOnePage = options.hideWhenOnePage || false; + this.paginationLabel = options.paginationLabel || gettext("Pagination"); this.collection.bind('add', _.bind(this.render, this)); this.collection.bind('remove', _.bind(this.render, this)); this.collection.bind('reset', _.bind(this.render, this)); @@ -32,7 +33,8 @@ } this.$el.html(_.template(paging_footer_template)({ current_page: this.collection.getPage(), - total_pages: this.collection.totalPages + total_pages: this.collection.totalPages, + paginationLabel: this.paginationLabel })); this.$(".previous-page-link").toggleClass("is-disabled", onFirstPage).attr('aria-disabled', onFirstPage); this.$(".next-page-link").toggleClass("is-disabled", onLastPage).attr('aria-disabled', onLastPage); diff --git a/common/static/common/js/components/views/tabbed_view.js b/common/static/common/js/components/views/tabbed_view.js new file mode 100644 index 0000000..375d9f3 --- /dev/null +++ b/common/static/common/js/components/views/tabbed_view.js @@ -0,0 +1,138 @@ +;(function (define) { + 'use strict'; + define(['backbone', + 'underscore', + 'jquery', + 'text!common/templates/components/tabbed_view.underscore', + 'text!common/templates/components/tab.underscore', + 'text!common/templates/components/tabpanel.underscore', + ], function ( + Backbone, + _, + $, + tabbedViewTemplate, + tabTemplate, + tabPanelTemplate + ) { + var getTabPanelId = function (id) { + return 'tabpanel-' + id; + }; + + var TabPanelView = Backbone.View.extend({ + template: _.template(tabPanelTemplate), + initialize: function (options) { + this.url = options.url; + this.view = options.view; + }, + render: function () { + var tabPanelHtml = this.template({tabId: getTabPanelId(this.url)}); + this.setElement($(tabPanelHtml)); + this.$el.append(this.view.render().el); + return this; + } + }); + + var TabbedView = Backbone.View.extend({ + events: { + 'click .nav-item.tab': 'switchTab' + }, + + /** + * View for a tabbed interface. Expects a list of tabs + * in its options object, each of which should contain the + * following properties: + * view (Backbone.View): the view to render for this tab. + * title (string): The title to display for this tab. + * url (string): The URL fragment which will + * navigate to this tab when a router is + * provided. + * If a router is passed in (via options.router), + * use that router to keep track of history between + * tabs. Backbone.history.start() must be called + * by the router's instantiator after this view is + * initialized. + */ + initialize: function (options) { + this.router = options.router || null; + this.tabs = options.tabs; + this.template = _.template(tabbedViewTemplate)({viewLabel: options.viewLabel}); + // Convert each view into a TabPanelView + _.each(this.tabs, function (tabInfo) { + tabInfo.view = new TabPanelView({url: tabInfo.url, view: tabInfo.view}); + }, this); + this.urlMap = _.reduce(this.tabs, function (map, value) { + map[value.url] = value; + return map; + }, {}); + }, + + render: function () { + var self = this; + this.$el.html(this.template); + _.each(this.tabs, function(tabInfo, index) { + var tabEl = $(_.template(tabTemplate)({ + index: index, + title: tabInfo.title, + url: tabInfo.url, + tabPanelId: getTabPanelId(tabInfo.url) + })), + tabContainerEl = this.$('.tabs'); + self.$('.page-content-nav').append(tabEl); + + // Render and append the current tab panel + tabContainerEl.append(tabInfo.view.render().$el); + }, this); + // Re-display the default (first) tab if the + // current route does not belong to one of the + // tabs. Otherwise continue displaying the tab + // corresponding to the current URL. + if (!(Backbone.history.getHash() in this.urlMap)) { + this.setActiveTab(0); + } + return this; + }, + + setActiveTab: function (index) { + var tabMeta = this.getTabMeta(index), + tab = tabMeta.tab, + tabEl = tabMeta.element, + view = tab.view; + // Hide old tab/tabpanel + this.$('button.is-active').removeClass('is-active').attr('aria-expanded', 'false'); + this.$('.tabpanel[aria-expanded="true"]').attr('aria-expanded', 'false').addClass('is-hidden'); + // Show new tab/tabpanel + tabEl.addClass('is-active').attr('aria-expanded', 'true'); + view.$el.attr('aria-expanded', 'true').removeClass('is-hidden'); + // This bizarre workaround makes focus work in Chrome. + _.defer(function () { + view.$('.sr-is-focusable.' + getTabPanelId(tab.url)).focus(); + }); + if (this.router) { + this.router.navigate(tab.url, {replace: true}); + } + }, + + switchTab: function (event) { + event.preventDefault(); + this.setActiveTab($(event.currentTarget).data('index')); + }, + + /** + * Get the tab by name or index. Returns an object + * encapsulating the tab object and its element. + */ + getTabMeta: function (tabNameOrIndex) { + var tab, element; + if (typeof tabNameOrIndex === 'string') { + tab = this.urlMap[tabNameOrIndex]; + element = this.$('button[data-url='+tabNameOrIndex+']'); + } else { + tab = this.tabs[tabNameOrIndex]; + element = this.$('button[data-index='+tabNameOrIndex+']'); + } + return {'tab': tab, 'element': element}; + } + }); + return TabbedView; + }); +}).call(this, define || RequireJS.define); diff --git a/common/static/common/js/spec/components/tabbed_view_spec.js b/common/static/common/js/spec/components/tabbed_view_spec.js new file mode 100644 index 0000000..edf9319 --- /dev/null +++ b/common/static/common/js/spec/components/tabbed_view_spec.js @@ -0,0 +1,138 @@ +(function (define) { + 'use strict'; + + define(['jquery', + 'underscore', + 'backbone', + 'common/js/components/views/tabbed_view' + ], + function($, _, Backbone, TabbedView) { + var view, + TestSubview = Backbone.View.extend({ + initialize: function (options) { + this.text = options.text; + }, + + render: function () { + this.$el.text(this.text); + return this; + } + }), + activeTab = function () { + return view.$('.page-content-nav'); + }, + activeTabPanel = function () { + return view.$('.tabpanel[aria-expanded="true"]'); + }; + + describe('TabbedView component', function () { + beforeEach(function () { + view = new TabbedView({ + tabs: [{ + title: 'Test 1', + view: new TestSubview({text: 'this is test text'}), + url: 'test-1' + }, { + title: 'Test 2', + view: new TestSubview({text: 'other text'}), + url: 'test-2' + }], + viewLabel: 'Tabs', + }).render(); + + // _.defer() is used to make calls to + // jQuery.focus() work in Chrome. _.defer() + // delays the execution of a function until the + // current call stack is clear. That behavior + // will cause tests to fail, so we'll instead + // make _.defer() immediately invoke its + // argument. + spyOn(_, 'defer').andCallFake(function (func) { + func(); + }); + }); + + it('can render itself', function () { + expect(view.$el.html()).toContain('<nav class="page-content-nav"'); + }); + + it('shows its first tab by default', function () { + expect(activeTabPanel().text()).toContain('this is test text'); + expect(activeTabPanel().text()).not.toContain('other text'); + }); + + it('displays titles for each tab', function () { + expect(activeTab().text()).toContain('Test 1'); + expect(activeTab().text()).toContain('Test 2'); + }); + + it('can switch tabs', function () { + view.$('.nav-item[data-index=1]').click(); + expect(activeTabPanel().text()).not.toContain('this is test text'); + expect(activeTabPanel().text()).toContain('other text'); + }); + + it('marks the active tab as selected using aria attributes', function () { + expect(view.$('.nav-item[data-index=0]')).toHaveAttr('aria-expanded', 'true'); + expect(view.$('.nav-item[data-index=1]')).toHaveAttr('aria-expanded', 'false'); + view.$('.nav-item[data-index=1]').click(); + expect(view.$('.nav-item[data-index=0]')).toHaveAttr('aria-expanded', 'false'); + expect(view.$('.nav-item[data-index=1]')).toHaveAttr('aria-expanded', 'true'); + }); + + it('sets focus for screen readers', function () { + spyOn($.fn, 'focus'); + view.$('.nav-item[data-url="test-2"]').click(); + expect(view.$('.sr-is-focusable.test-2').focus).toHaveBeenCalled(); + }); + + describe('history', function() { + beforeEach(function () { + spyOn(Backbone.history, 'navigate').andCallThrough(); + view = new TabbedView({ + tabs: [{ + url: 'test 1', + title: 'Test 1', + view: new TestSubview({text: 'this is test text'}) + }, { + url: 'test 2', + title: 'Test 2', + view: new TestSubview({text: 'other text'}) + }], + router: new Backbone.Router({ + routes: { + 'test 1': function () { + view.setActiveTab(0); + }, + 'test 2': function () { + view.setActiveTab(1); + } + } + }) + }).render(); + Backbone.history.start(); + }); + + afterEach(function () { + view.router.navigate(''); + Backbone.history.stop(); + }); + + it('updates the page URL on tab switches without adding to browser history', function () { + view.$('.nav-item[data-index=1]').click(); + expect(Backbone.history.navigate).toHaveBeenCalledWith( + 'test 2', + {replace: true} + ); + }); + + it('changes tabs on URL navigation', function () { + expect(view.$('.nav-item.is-active').data('index')).toEqual(0); + Backbone.history.navigate('test 2', {trigger: true}); + expect(view.$('.nav-item.is-active').data('index')).toEqual(1); + }); + }); + + }); + }); +}).call(this, define || RequireJS.define); diff --git a/common/static/common/js/spec/main_requirejs.js b/common/static/common/js/spec/main_requirejs.js index 23d8adf..94a40ca 100644 --- a/common/static/common/js/spec/main_requirejs.js +++ b/common/static/common/js/spec/main_requirejs.js @@ -155,6 +155,7 @@ define([ // Run the common tests that use RequireJS. + 'common-requirejs/include/common/js/spec/components/tabbed_view_spec.js', 'common-requirejs/include/common/js/spec/components/feedback_spec.js', 'common-requirejs/include/common/js/spec/components/list_spec.js', 'common-requirejs/include/common/js/spec/components/paginated_view_spec.js', diff --git a/common/static/common/js/spec_helpers/ajax_helpers.js b/common/static/common/js/spec_helpers/ajax_helpers.js index 665da71..5cf3f0c 100644 --- a/common/static/common/js/spec_helpers/ajax_helpers.js +++ b/common/static/common/js/spec_helpers/ajax_helpers.js @@ -72,6 +72,10 @@ define(['sinon', 'underscore', 'URI'], function(sinon, _, URI) { expect(request.readyState).toEqual(XML_HTTP_READY_STATES.OPENED); expect(request.url).toEqual(url); expect(request.method).toEqual(method); + if (typeof body === 'undefined') { + // The contents if this call may not be germane to the current test. + return; + } expect(request.requestBody).toEqual(body); }; diff --git a/common/static/common/templates/components/paging-footer.underscore b/common/static/common/templates/components/paging-footer.underscore index d04bbc0..28c34b6 100644 --- a/common/static/common/templates/components/paging-footer.underscore +++ b/common/static/common/templates/components/paging-footer.underscore @@ -1,8 +1,12 @@ -<nav class="pagination pagination-full bottom" aria-label="Pagination"> +<nav class="pagination pagination-full bottom" aria-label="<%= paginationLabel %>"> <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"> - <label class="page-number-label" for="page-number-input"><%= gettext("Page number") %></label> + <label class="page-number-label" for="page-number-input"><%= interpolate( + gettext("Page number out of %(total_pages)s"), + {total_pages: total_pages}, + true + )%></label> <input id="page-number-input" class="page-number-input" name="page-number" type="text" size="4" autocomplete="off" aria-describedby="page-number-input-helper"/> <span class="sr field-helper" id="page-number-input-helper"><%= gettext("Enter the page number you'd like to quickly navigate to.") %></span> </div> diff --git a/common/static/common/templates/components/tab.underscore b/common/static/common/templates/components/tab.underscore new file mode 100644 index 0000000..d7c5e37 --- /dev/null +++ b/common/static/common/templates/components/tab.underscore @@ -0,0 +1 @@ +<button class="nav-item tab" data-url="<%= url %>" data-index="<%= index %>" is-active="false" aria-expanded="false" aria-controls="<%= tabPanelId %>"><%= title %></button> diff --git a/common/static/common/templates/components/tabbed_view.underscore b/common/static/common/templates/components/tabbed_view.underscore new file mode 100644 index 0000000..014980e --- /dev/null +++ b/common/static/common/templates/components/tabbed_view.underscore @@ -0,0 +1,4 @@ +<nav class="page-content-nav" aria-label="<%- viewLabel %>"></nav> +<div class="page-content-main"> + <div class="tabs"></div> +</div> diff --git a/common/static/common/templates/components/tabpanel.underscore b/common/static/common/templates/components/tabpanel.underscore new file mode 100644 index 0000000..c1104c1 --- /dev/null +++ b/common/static/common/templates/components/tabpanel.underscore @@ -0,0 +1,3 @@ +<div class="tabpanel is-hidden" id="<%= tabId %>" aria-expanded="false"> + <div class="sr-is-focusable <%= tabId %>" tabindex="-1"></div> +</div> diff --git a/lms/djangoapps/badges/api/serializers.py b/lms/djangoapps/badges/api/serializers.py index 65c2bea..bd3739b 100644 --- a/lms/djangoapps/badges/api/serializers.py +++ b/lms/djangoapps/badges/api/serializers.py @@ -1,5 +1,5 @@ """ -Serializers for Badges +Serializers for badging """ from rest_framework import serializers @@ -25,4 +25,4 @@ class BadgeAssertionSerializer(serializers.ModelSerializer): class Meta(object): model = BadgeAssertion - fields = ('badge_class', 'image_url', 'assertion_url') + fields = ('badge_class', 'image_url', 'assertion_url', 'created') diff --git a/lms/djangoapps/badges/api/views.py b/lms/djangoapps/badges/api/views.py index ec18442..ad91e2b 100644 --- a/lms/djangoapps/badges/api/views.py +++ b/lms/djangoapps/badges/api/views.py @@ -6,8 +6,12 @@ from opaque_keys.edx.keys import CourseKey from rest_framework import generics from rest_framework.exceptions import APIException +from openedx.core.djangoapps.user_api.permissions import is_field_shared_factory +from openedx.core.lib.api.authentication import ( + OAuth2AuthenticationAllowInactiveUser, + SessionAuthenticationAllowInactiveUser +) from badges.models import BadgeAssertion -from openedx.core.lib.api.view_utils import view_auth_classes from .serializers import BadgeAssertionSerializer from xmodule_django.models import CourseKeyField @@ -20,7 +24,6 @@ class CourseKeyError(APIException): default_detail = "The course key provided could not be parsed." -@view_auth_classes(is_user=True) class UserBadgeAssertions(generics.ListAPIView): """ ** Use cases ** @@ -89,6 +92,17 @@ class UserBadgeAssertions(generics.ListAPIView): } """ serializer_class = BadgeAssertionSerializer + authentication_classes = ( + OAuth2AuthenticationAllowInactiveUser, + SessionAuthenticationAllowInactiveUser + ) + permission_classes = (is_field_shared_factory("accomplishments_shared"),) + + def filter_queryset(self, queryset): + """ + Return most recent to least recent badge. + """ + return queryset.order_by('-created') def get_queryset(self): """ diff --git a/lms/djangoapps/badges/migrations/0001_initial.py b/lms/djangoapps/badges/migrations/0001_initial.py index 000d230..2ffcb14 100644 --- a/lms/djangoapps/badges/migrations/0001_initial.py +++ b/lms/djangoapps/badges/migrations/0001_initial.py @@ -5,6 +5,8 @@ from django.db import migrations, models import jsonfield.fields import badges.models from django.conf import settings +import django.utils.timezone +from model_utils import fields import xmodule_django.models @@ -23,14 +25,16 @@ class Migration(migrations.Migration): ('backend', models.CharField(max_length=50)), ('image_url', models.URLField()), ('assertion_url', models.URLField()), + ('modified', fields.AutoLastModifiedField(default=django.utils.timezone.now, verbose_name='modified', editable=False)), + ('created', fields.AutoCreatedField(default=django.utils.timezone.now, verbose_name='created', editable=False, db_index=True)), ], ), migrations.CreateModel( name='BadgeClass', fields=[ ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), - ('slug', models.SlugField(max_length=255)), - ('issuing_component', models.SlugField(default=b'', blank=True)), + ('slug', models.SlugField(max_length=255, validators=[badges.models.validate_lowercase])), + ('issuing_component', models.SlugField(default=b'', blank=True, validators=[badges.models.validate_lowercase])), ('display_name', models.CharField(max_length=255)), ('course_id', xmodule_django.models.CourseKeyField(default=None, max_length=255, blank=True)), ('description', models.TextField()), diff --git a/lms/djangoapps/badges/migrations/0002_data__migrate_assertions.py b/lms/djangoapps/badges/migrations/0002_data__migrate_assertions.py index 56be82d..0194297 100644 --- a/lms/djangoapps/badges/migrations/0002_data__migrate_assertions.py +++ b/lms/djangoapps/badges/migrations/0002_data__migrate_assertions.py @@ -2,8 +2,10 @@ from __future__ import unicode_literals import json +from datetime import datetime import os +import time from django.db import migrations, models @@ -47,14 +49,20 @@ def forwards(apps, schema_editor): data = badge.data else: data = json.dumps(badge.data) - BadgeAssertion( + assertion = BadgeAssertion( user_id=badge.user_id, badge_class=classes[(badge.course_id, badge.mode)], data=data, backend='BadgrBackend', image_url=badge.data['image'], assertion_url=badge.data['json']['id'], - ).save() + ) + assertion.save() + # Would be overwritten by the first save. + assertion.created = datetime.fromtimestamp( + time.mktime(time.strptime(badge.data['created_at'], "%Y-%m-%dT%H:%M:%S")) + ) + assertion.save() for configuration in BadgeImageConfiguration.objects.all(): file_content = ContentFile(configuration.icon.read()) diff --git a/lms/djangoapps/badges/migrations/0003_schema__add_event_configuration.py b/lms/djangoapps/badges/migrations/0003_schema__add_event_configuration.py index aefbf1d..557408b 100644 --- a/lms/djangoapps/badges/migrations/0003_schema__add_event_configuration.py +++ b/lms/djangoapps/badges/migrations/0003_schema__add_event_configuration.py @@ -20,9 +20,9 @@ class Migration(migrations.Migration): ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), ('change_date', models.DateTimeField(auto_now_add=True, verbose_name='Change date')), ('enabled', models.BooleanField(default=False, verbose_name='Enabled')), - ('courses_completed', models.TextField(default=b'', help_text="On each line, put the number of completed courses to award a badge for, a comma, and the slug of a badge class you have created with the issuing component 'edx__course'. For example: 3,course-v1:edx/Demo/DemoX", blank=True)), - ('courses_enrolled', models.TextField(default=b'', help_text="On each line, put the number of enrolled courses to award a badge for, a comma, and the slug of a badge class you have created with the issuing component 'edx__course'. For example: 3,course-v1:edx/Demo/DemoX", blank=True)), - ('course_groups', models.TextField(default=b'', help_text="On each line, put the slug of a badge class you have created with the issuing component 'edx__course' to award, a comma, and a comma separated list of course keys that the user will need to complete to get this badge. For example: slug_for_compsci_courses_group_badge,course-v1:CompSci+Course+First,course-v1:CompsSci+Course+Second", blank=True)), + ('courses_completed', models.TextField(default=b'', help_text="On each line, put the number of completed courses to award a badge for, a comma, and the slug of a badge class you have created with the issuing component 'edx__course'. For example: 3,enrolled_3_courses", blank=True)), + ('courses_enrolled', models.TextField(default=b'', help_text="On each line, put the number of enrolled courses to award a badge for, a comma, and the slug of a badge class you have created with the issuing component 'edx__course'. For example: 3,enrolled_3_courses", blank=True)), + ('course_groups', models.TextField(default=b'', help_text="Each line is a comma-separated list. The first item in each line is the slug of a badge class to award, with an issuing component of 'edx__course'. The remaining items in each line are the course keys the user will need to complete to be awarded the badge. For example: slug_for_compsci_courses_group_badge,course-v1:CompSci+Course+First,course-v1:CompsSci+Course+Second", blank=True)), ('changed_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, editable=False, to=settings.AUTH_USER_MODEL, null=True, verbose_name='Changed by')), ], ), diff --git a/lms/djangoapps/badges/models.py b/lms/djangoapps/badges/models.py index 19c8eec..60847e8 100644 --- a/lms/djangoapps/badges/models.py +++ b/lms/djangoapps/badges/models.py @@ -9,6 +9,7 @@ from django.core.exceptions import ValidationError from django.db import models from django.utils.translation import ugettext_lazy as _ from lazy import lazy +from model_utils.models import TimeStampedModel from opaque_keys import InvalidKeyError from opaque_keys.edx.keys import CourseKey @@ -122,7 +123,7 @@ class BadgeClass(models.Model): verbose_name_plural = "Badge Classes" -class BadgeAssertion(models.Model): +class BadgeAssertion(TimeStampedModel): """ Tracks badges on our side of the badge baking transaction """ @@ -152,6 +153,10 @@ class BadgeAssertion(models.Model): app_label = "badges" +# Abstract model doesn't index this, so we have to. +BadgeAssertion._meta.get_field('created').db_index = True # pylint: disable=protected-access + + class CourseCompleteImageConfiguration(models.Model): """ Contains the icon configuration for badges for a specific course mode. @@ -216,7 +221,7 @@ class CourseEventBadgesConfiguration(ConfigurationModel): help_text=_( u"On each line, put the number of completed courses to award a badge for, a comma, and the slug of a " u"badge class you have created with the issuing component 'edx__course'. " - u"For example: 3,course-v1:edx/Demo/DemoX" + u"For example: 3,enrolled_3_courses" ) ) courses_enrolled = models.TextField( @@ -224,7 +229,7 @@ class CourseEventBadgesConfiguration(ConfigurationModel): help_text=_( u"On each line, put the number of enrolled courses to award a badge for, a comma, and the slug of a " u"badge class you have created with the issuing component 'edx__course'. " - u"For example: 3,course-v1:edx/Demo/DemoX" + u"For example: 3,enrolled_3_courses" ) ) course_groups = models.TextField( @@ -232,8 +237,8 @@ class CourseEventBadgesConfiguration(ConfigurationModel): help_text=_( u"Each line is a comma-separated list. The first item in each line is the slug of a badge class to award, " u"with an issuing component of 'edx__course'. The remaining items in each line are the course keys the " - u"user will need to complete to get the badge. For example: slug_for_compsci_courses_group_badge,course-v1" - u":CompSci+Course+First,course-v1:CompsSci+Course+Second" + u"user will need to complete to be awarded the badge. For example: " + u"slug_for_compsci_courses_group_badge,course-v1:CompSci+Course+First,course-v1:CompsSci+Course+Second" ) ) diff --git a/lms/djangoapps/student_profile/views.py b/lms/djangoapps/student_profile/views.py index 7ef5787..0ee58e6 100644 --- a/lms/djangoapps/student_profile/views.py +++ b/lms/djangoapps/student_profile/views.py @@ -1,21 +1,19 @@ """ Views for a student's profile information. """ from django.conf import settings +from django.contrib.auth.decorators import login_required from django.core.exceptions import ObjectDoesNotExist -from django_countries import countries - from django.core.urlresolvers import reverse -from django.contrib.auth.decorators import login_required from django.http import Http404 from django.views.decorators.http import require_http_methods +from django_countries import countries -from edxmako.shortcuts import render_to_response +from edxmako.shortcuts import render_to_response, marketing_link +from microsite_configuration import microsite from openedx.core.djangoapps.user_api.accounts.api import get_account_settings -from openedx.core.djangoapps.user_api.accounts.serializers import PROFILE_IMAGE_KEY_PREFIX from openedx.core.djangoapps.user_api.errors import UserNotFound, UserNotAuthorized from openedx.core.djangoapps.user_api.preferences.api import get_user_preferences from student.models import User -from microsite_configuration import microsite @login_required @@ -87,9 +85,14 @@ def learner_profile_context(request, profile_username, user_is_staff): 'has_preferences_access': (logged_in_user.username == profile_username or user_is_staff), 'own_profile': own_profile, 'country_options': list(countries), + 'find_courses_url': marketing_link('COURSES'), 'language_options': settings.ALL_LANGUAGES, 'platform_name': microsite.get_value('platform_name', settings.PLATFORM_NAME), }, 'disable_courseware_js': True, } + + if settings.FEATURES.get("ENABLE_OPENBADGES"): + context['data']['badges_api_url'] = reverse("badges_api:user_assertions", kwargs={'username': profile_username}) + return context diff --git a/lms/djangoapps/teams/static/teams/js/collections/team.js b/lms/djangoapps/teams/static/teams/js/collections/team.js index 3b0fac8..c770a7b 100644 --- a/lms/djangoapps/teams/static/teams/js/collections/team.js +++ b/lms/djangoapps/teams/static/teams/js/collections/team.js @@ -10,13 +10,13 @@ BaseCollection.prototype.initialize.call(this, options); this.server_api = _.extend( + this.server_api, { topic_id: this.topic_id = options.topic_id, expand: 'user', course_id: function () { return encodeURIComponent(self.course_id); }, order_by: function () { return self.searchString ? '' : this.sortField; } - }, - BaseCollection.prototype.server_api + } ); delete this.server_api.sort_order; // Sort order is not specified for the Team API diff --git a/lms/djangoapps/teams/static/teams/js/collections/topic.js b/lms/djangoapps/teams/static/teams/js/collections/topic.js index 581c6b7..bcb46e5 100644 --- a/lms/djangoapps/teams/static/teams/js/collections/topic.js +++ b/lms/djangoapps/teams/static/teams/js/collections/topic.js @@ -11,11 +11,11 @@ this.perPage = topics.results.length; this.server_api = _.extend( + this.server_api, { course_id: function () { return encodeURIComponent(self.course_id); }, order_by: function () { return this.sortField; } - }, - BaseCollection.prototype.server_api + } ); delete this.server_api['sort_order']; // Sort order is not specified for the Team API diff --git a/lms/djangoapps/teams/static/teams/js/teams_tab_factory.js b/lms/djangoapps/teams/static/teams/js/teams_tab_factory.js index bd4c4f8..ad7ae52 100644 --- a/lms/djangoapps/teams/static/teams/js/teams_tab_factory.js +++ b/lms/djangoapps/teams/static/teams/js/teams_tab_factory.js @@ -5,7 +5,8 @@ return function (options) { var teamsTab = new TeamsTabView({ el: $('.teams-content'), - context: options + context: options, + viewLabel: gettext("Teams") }); teamsTab.start(); }; diff --git a/lms/djangoapps/teams/static/teams/js/views/teams.js b/lms/djangoapps/teams/static/teams/js/views/teams.js index d163761..9e85cec 100644 --- a/lms/djangoapps/teams/static/teams/js/views/teams.js +++ b/lms/djangoapps/teams/static/teams/js/views/teams.js @@ -15,6 +15,8 @@ text: gettext('All teams') }, + paginationLabel: gettext('Teams Pagination'), + initialize: function (options) { this.context = options.context; this.itemViewClass = TeamCardView.extend({ diff --git a/lms/djangoapps/teams/static/teams/js/views/teams_tabbed_view.js b/lms/djangoapps/teams/static/teams/js/views/teams_tabbed_view.js index 94c16f0..a3be6db 100644 --- a/lms/djangoapps/teams/static/teams/js/views/teams_tabbed_view.js +++ b/lms/djangoapps/teams/static/teams/js/views/teams_tabbed_view.js @@ -5,7 +5,7 @@ 'use strict'; define([ - 'js/components/tabbed/views/tabbed_view', + 'common/js/components/views/tabbed_view', 'teams/js/utils/team_analytics' ], function (TabbedView, TeamAnalytics) { var TeamsTabbedView = TabbedView.extend({ diff --git a/lms/djangoapps/teams/views.py b/lms/djangoapps/teams/views.py index 98c079d..4ed307c 100644 --- a/lms/djangoapps/teams/views.py +++ b/lms/djangoapps/teams/views.py @@ -77,37 +77,12 @@ def team_post_save_callback(sender, instance, **kwargs): # pylint: disable=unus ) -class TeamAPIPagination(DefaultPagination): - """ - Pagination format used by the teams API. - """ - page_size_query_param = "page_size" - - def get_paginated_response(self, data): - """ - Annotate the response with pagination information. - """ - response = super(TeamAPIPagination, self).get_paginated_response(data) - - # Add the current page to the response. - # It may make sense to eventually move this field into the default - # implementation, but for now, teams is the only API that uses this. - response.data["current_page"] = self.page.number - - # This field can be derived from other fields in the response, - # so it may make sense to have the JavaScript client calculate it - # instead of including it in the response. - response.data["start"] = (self.page.number - 1) * self.get_page_size(self.request) - - return response - - -class TopicsPagination(TeamAPIPagination): +class TopicsPagination(DefaultPagination): """Paginate topics. """ page_size = TOPICS_PER_PAGE -class MyTeamsPagination(TeamAPIPagination): +class MyTeamsPagination(DefaultPagination): """Paginate the user's teams. """ page_size = TEAM_MEMBERSHIPS_PER_PAGE @@ -381,7 +356,6 @@ class TeamsListView(ExpandableFieldViewMixin, GenericAPIView): authentication_classes = (OAuth2Authentication, SessionAuthentication) permission_classes = (permissions.IsAuthenticated,) serializer_class = CourseTeamSerializer - pagination_class = TeamAPIPagination def get(self, request): """GET /api/team/v0/teams/""" diff --git a/lms/envs/common.py b/lms/envs/common.py index fe16efa..200037c 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -2644,6 +2644,8 @@ ACCOUNT_VISIBILITY_CONFIGURATION = { 'language_proficiencies', 'bio', 'account_privacy', + # Not an actual field, but used to signal whether badges should be public. + 'accomplishments_shared', ], # The list of account fields that are always public @@ -2671,6 +2673,7 @@ ACCOUNT_VISIBILITY_CONFIGURATION = { "mailing_address", "requires_parental_consent", "account_privacy", + "accomplishments_shared", ] } diff --git a/lms/static/js/bookmarks/collections/bookmarks.js b/lms/static/js/bookmarks/collections/bookmarks.js index 5897d33..0188ce6 100644 --- a/lms/static/js/bookmarks/collections/bookmarks.js +++ b/lms/static/js/bookmarks/collections/bookmarks.js @@ -13,7 +13,7 @@ course_id: function () { return encodeURIComponent(options.course_id); }, fields : function () { return encodeURIComponent('display_name,path'); } }, - PagingCollection.prototype.server_api + this.server_api ); delete this.server_api.sort_order; // Sort order is not specified for the Bookmark API }, diff --git a/lms/static/js/components/tabbed/views/tabbed_view.js b/lms/static/js/components/tabbed/views/tabbed_view.js deleted file mode 100644 index 367a803..0000000 --- a/lms/static/js/components/tabbed/views/tabbed_view.js +++ /dev/null @@ -1,139 +0,0 @@ -;(function (define) { - 'use strict'; - define(['backbone', - 'underscore', - 'jquery', - 'text!templates/components/tabbed/tabbed_view.underscore', - 'text!templates/components/tabbed/tab.underscore', - 'text!templates/components/tabbed/tabpanel.underscore', - ], function ( - Backbone, - _, - $, - tabbedViewTemplate, - tabTemplate, - tabPanelTemplate - ) { - var getTabPanelId = function (id) { - return 'tabpanel-' + id; - }; - - var TabPanelView = Backbone.View.extend({ - template: _.template(tabPanelTemplate), - initialize: function (options) { - this.url = options.url; - this.view = options.view; - }, - render: function () { - var tabPanelHtml = this.template({tabId: getTabPanelId(this.url)}); - this.setElement($(tabPanelHtml)); - this.$el.append(this.view.render().el); - return this; - } - }); - - var TabbedView = Backbone.View.extend({ - events: { - 'click .nav-item.tab': 'switchTab' - }, - - template: _.template(tabbedViewTemplate), - - /** - * View for a tabbed interface. Expects a list of tabs - * in its options object, each of which should contain the - * following properties: - * view (Backbone.View): the view to render for this tab. - * title (string): The title to display for this tab. - * url (string): The URL fragment which will - * navigate to this tab when a router is - * provided. - * If a router is passed in (via options.router), - * use that router to keep track of history between - * tabs. Backbone.history.start() must be called - * by the router's instatiator after this view is - * initialized. - */ - initialize: function (options) { - this.router = options.router || null; - this.tabs = options.tabs; - // Convert each view into a TabPanelView - _.each(this.tabs, function (tabInfo) { - tabInfo.view = new TabPanelView({url: tabInfo.url, view: tabInfo.view}); - }, this); - this.urlMap = _.reduce(this.tabs, function (map, value) { - map[value.url] = value; - return map; - }, {}); - }, - - render: function () { - var self = this; - this.$el.html(this.template({})); - _.each(this.tabs, function(tabInfo, index) { - var tabEl = $(_.template(tabTemplate)({ - index: index, - title: tabInfo.title, - url: tabInfo.url, - tabPanelId: getTabPanelId(tabInfo.url) - })), - tabContainerEl = this.$('.tabs'); - self.$('.page-content-nav').append(tabEl); - - // Render and append the current tab panel - tabContainerEl.append(tabInfo.view.render().$el); - }, this); - // Re-display the default (first) tab if the - // current route does not belong to one of the - // tabs. Otherwise continue displaying the tab - // corresponding to the current URL. - if (!(Backbone.history.getHash() in this.urlMap)) { - this.setActiveTab(0); - } - return this; - }, - - setActiveTab: function (index) { - var tabMeta = this.getTabMeta(index), - tab = tabMeta.tab, - tabEl = tabMeta.element, - view = tab.view; - // Hide old tab/tabpanel - this.$('button.is-active').removeClass('is-active').attr('aria-expanded', 'false'); - this.$('.tabpanel[aria-expanded="true"]').attr('aria-expanded', 'false').addClass('is-hidden'); - // Show new tab/tabpanel - tabEl.addClass('is-active').attr('aria-expanded', 'true'); - view.$el.attr('aria-expanded', 'true').removeClass('is-hidden'); - // This bizarre workaround makes focus work in Chrome. - _.defer(function () { - view.$('.sr-is-focusable.' + getTabPanelId(tab.url)).focus(); - }); - if (this.router) { - this.router.navigate(tab.url, {replace: true}); - } - }, - - switchTab: function (event) { - event.preventDefault(); - this.setActiveTab($(event.currentTarget).data('index')); - }, - - /** - * Get the tab by name or index. Returns an object - * encapsulating the tab object and its element. - */ - getTabMeta: function (tabNameOrIndex) { - var tab, element; - if (typeof tabNameOrIndex === 'string') { - tab = this.urlMap[tabNameOrIndex]; - element = this.$('button[data-url='+tabNameOrIndex+']'); - } else { - tab = this.tabs[tabNameOrIndex]; - element = this.$('button[data-index='+tabNameOrIndex+']'); - } - return {'tab': tab, 'element': element}; - } - }); - return TabbedView; - }); -}).call(this, define || RequireJS.define); diff --git a/lms/static/js/edxnotes/collections/notes.js b/lms/static/js/edxnotes/collections/notes.js index b5a6f00..c6e5fad 100644 --- a/lms/static/js/edxnotes/collections/notes.js +++ b/lms/static/js/edxnotes/collections/notes.js @@ -10,7 +10,7 @@ define([ PagingCollection.prototype.initialize.call(this); this.perPage = options.perPage; - this.server_api = _.pick(PagingCollection.prototype.server_api, "page", "page_size"); + this.server_api = _.pick(this.server_api, "page", "page_size"); if (options.text) { this.server_api.text = options.text; } diff --git a/lms/static/js/spec/components/tabbed/tabbed_view_spec.js b/lms/static/js/spec/components/tabbed/tabbed_view_spec.js deleted file mode 100644 index cebe44f..0000000 --- a/lms/static/js/spec/components/tabbed/tabbed_view_spec.js +++ /dev/null @@ -1,137 +0,0 @@ -(function (define) { - 'use strict'; - - define(['jquery', - 'underscore', - 'backbone', - 'js/components/tabbed/views/tabbed_view' - ], - function($, _, Backbone, TabbedView) { - var view, - TestSubview = Backbone.View.extend({ - initialize: function (options) { - this.text = options.text; - }, - - render: function () { - this.$el.text(this.text); - return this; - } - }), - activeTab = function () { - return view.$('.page-content-nav'); - }, - activeTabPanel = function () { - return view.$('.tabpanel[aria-expanded="true"]'); - }; - - describe('TabbedView component', function () { - beforeEach(function () { - view = new TabbedView({ - tabs: [{ - title: 'Test 1', - view: new TestSubview({text: 'this is test text'}), - url: 'test-1' - }, { - title: 'Test 2', - view: new TestSubview({text: 'other text'}), - url: 'test-2' - }] - }).render(); - - // _.defer() is used to make calls to - // jQuery.focus() work in Chrome. _.defer() - // delays the execution of a function until the - // current call stack is clear. That behavior - // will cause tests to fail, so we'll instead - // make _.defer() immediately invoke its - // argument. - spyOn(_, 'defer').andCallFake(function (func) { - func(); - }); - }); - - it('can render itself', function () { - expect(view.$el.html()).toContain('<nav class="page-content-nav"'); - }); - - it('shows its first tab by default', function () { - expect(activeTabPanel().text()).toContain('this is test text'); - expect(activeTabPanel().text()).not.toContain('other text'); - }); - - it('displays titles for each tab', function () { - expect(activeTab().text()).toContain('Test 1'); - expect(activeTab().text()).toContain('Test 2'); - }); - - it('can switch tabs', function () { - view.$('.nav-item[data-index=1]').click(); - expect(activeTabPanel().text()).not.toContain('this is test text'); - expect(activeTabPanel().text()).toContain('other text'); - }); - - it('marks the active tab as selected using aria attributes', function () { - expect(view.$('.nav-item[data-index=0]')).toHaveAttr('aria-expanded', 'true'); - expect(view.$('.nav-item[data-index=1]')).toHaveAttr('aria-expanded', 'false'); - view.$('.nav-item[data-index=1]').click(); - expect(view.$('.nav-item[data-index=0]')).toHaveAttr('aria-expanded', 'false'); - expect(view.$('.nav-item[data-index=1]')).toHaveAttr('aria-expanded', 'true'); - }); - - it('sets focus for screen readers', function () { - spyOn($.fn, 'focus'); - view.$('.nav-item[data-url="test-2"]').click(); - expect(view.$('.sr-is-focusable.test-2').focus).toHaveBeenCalled(); - }); - - describe('history', function() { - beforeEach(function () { - spyOn(Backbone.history, 'navigate').andCallThrough(); - view = new TabbedView({ - tabs: [{ - url: 'test 1', - title: 'Test 1', - view: new TestSubview({text: 'this is test text'}) - }, { - url: 'test 2', - title: 'Test 2', - view: new TestSubview({text: 'other text'}) - }], - router: new Backbone.Router({ - routes: { - 'test 1': function () { - view.setActiveTab(0); - }, - 'test 2': function () { - view.setActiveTab(1); - } - } - }) - }).render(); - Backbone.history.start(); - }); - - afterEach(function () { - view.router.navigate(''); - Backbone.history.stop(); - }); - - it('updates the page URL on tab switches without adding to browser history', function () { - view.$('.nav-item[data-index=1]').click(); - expect(Backbone.history.navigate).toHaveBeenCalledWith( - 'test 2', - {replace: true} - ); - }); - - it('changes tabs on URL navigation', function () { - expect(view.$('.nav-item.is-active').data('index')).toEqual(0); - Backbone.history.navigate('test 2', {trigger: true}); - expect(view.$('.nav-item.is-active').data('index')).toEqual(1); - }); - }); - - }); - }); -}).call(this, define || RequireJS.define); diff --git a/lms/static/js/spec/main.js b/lms/static/js/spec/main.js index cc72307..89dda4e 100644 --- a/lms/static/js/spec/main.js +++ b/lms/static/js/spec/main.js @@ -633,7 +633,6 @@ define([ // Run the LMS tests 'lms/include/js/spec/components/header/header_spec.js', - 'lms/include/js/spec/components/tabbed/tabbed_view_spec.js', 'lms/include/js/spec/components/card/card_spec.js', 'lms/include/js/spec/staff_debug_actions_spec.js', 'lms/include/js/spec/views/notification_spec.js', diff --git a/lms/static/js/spec/student_account/helpers.js b/lms/static/js/spec/student_account/helpers.js index 9700126..67a33b7 100644 --- a/lms/static/js/spec/student_account/helpers.js +++ b/lms/static/js/spec/student_account/helpers.js @@ -3,8 +3,10 @@ define(['underscore'], function(_) { var USER_ACCOUNTS_API_URL = '/api/user/v0/accounts/student'; var USER_PREFERENCES_API_URL = '/api/user/v0/preferences/student'; + var BADGES_API_URL = '/api/badges/v1/assertions/student/'; var IMAGE_UPLOAD_API_URL = '/api/profile_images/v0/staff/upload'; var IMAGE_REMOVE_API_URL = '/api/profile_images/v0/staff/remove'; + var FIND_COURSES_URL = '/courses'; var PROFILE_IMAGE = { image_url_large: '/media/profile-images/image.jpg', @@ -23,7 +25,8 @@ define(['underscore'], function(_) { language: null, bio: "About the student", language_proficiencies: [{code: '1'}], - profile_image: PROFILE_IMAGE + profile_image: PROFILE_IMAGE, + accomplishments_shared: false }; var createAccountSettingsData = function(options) { @@ -109,6 +112,8 @@ define(['underscore'], function(_) { return { USER_ACCOUNTS_API_URL: USER_ACCOUNTS_API_URL, USER_PREFERENCES_API_URL: USER_PREFERENCES_API_URL, + BADGES_API_URL: BADGES_API_URL, + FIND_COURSES_URL: FIND_COURSES_URL, IMAGE_UPLOAD_API_URL: IMAGE_UPLOAD_API_URL, IMAGE_REMOVE_API_URL: IMAGE_REMOVE_API_URL, IMAGE_MAX_BYTES: IMAGE_MAX_BYTES, diff --git a/lms/static/js/spec/student_profile/helpers.js b/lms/static/js/spec/student_profile/helpers.js index 594d952..b5ca701 100644 --- a/lms/static/js/spec/student_profile/helpers.js +++ b/lms/static/js/spec/student_profile/helpers.js @@ -93,7 +93,7 @@ define(['underscore'], function(_) { if (othersProfile) { expect($('.profile-private--message').text()) - .toBe('This edX learner is currently sharing a limited profile.'); + .toBe('This learner is currently sharing a limited profile.'); } else { expect($('.profile-private--message').text()).toBe('You are currently sharing a limited profile.'); } @@ -105,9 +105,122 @@ define(['underscore'], function(_) { expect(learnerProfileView.$('.wrapper-profile-section-two').length).toBe(0); }; + var expectTabbedViewToBeHidden = function(requests, tabbedViewView) { + // Unrelated initial request, no badge request + expect(requests.length).toBe(1); + expect(tabbedViewView.$el.find('.page-content-nav').is(':visible')).toBe(false); + }; + + var expectTabbedViewToBeShown = function(tabbedViewView) { + expect(tabbedViewView.$el.find('.page-content-nav').is(':visible')).toBe(true); + }; + + var expectBadgesDisplayed = function(learnerProfileView, length, lastPage) { + var badgeListingView = learnerProfileView.$el.find('#tabpanel-accomplishments'); + expect(learnerProfileView.$el.find('#tabpanel-about_me').hasClass('is-hidden')).toBe(true); + expect(badgeListingView.hasClass('is-hidden')).toBe(false); + if (lastPage) { + length += 1; + var placeholder = badgeListingView.find('.find-course'); + expect(placeholder.length).toBe(1); + expect(placeholder.attr('href')).toBe('/courses/'); + } + expect(badgeListingView.find('.badge-display').length).toBe(length); + }; + + var expectBadgesHidden = function(learnerProfileView) { + var accomplishmentsTab = learnerProfileView.$el.find('#tabpanel-accomplishments'); + if (accomplishmentsTab.length) { + // Nonexistence counts as hidden. + expect(learnerProfileView.$el.find('#tabpanel-accomplishments').hasClass('is-hidden')).toBe(true); + } + expect(learnerProfileView.$el.find('#tabpanel-about_me').hasClass('is-hidden')).toBe(false); + }; + + var expectPage = function(learnerProfileView, pageData) { + var badgeListContainer = learnerProfileView.$el.find('#tabpanel-accomplishments'); + var index = badgeListContainer.find('span.search-count').text().trim(); + expect(index).toBe("Showing " + (pageData.start + 1) + "-" + (pageData.start + pageData.results.length) + + " out of " + pageData.count + " total"); + expect(badgeListContainer.find('.current-page').text()).toBe("" + pageData.current_page); + _.each(pageData.results, function(badge) { + expect($(".badge-display:contains(" + badge.badge_class.display_name + ")").length).toBe(1); + }); + }; + + var firstPageBadges = { + count: 30, + previous: null, + next: "/arbitrary/url", + num_pages: 3, + start: 0, + current_page: 1, + results: [] + }; + + var secondPageBadges = { + count: 30, + previous: "/arbitrary/url", + next: "/arbitrary/url", + num_pages: 3, + start: 10, + current_page: 2, + results: [] + }; + + var thirdPageBadges = { + count: 30, + previous: "/arbitrary/url", + num_pages: 3, + next: null, + start: 20, + current_page: 3, + results: [] + }; + + function makeBadge (num) { + return { + "badge_class": { + "slug": "test_slug_" + num, + "issuing_component": "test_component", + "display_name": "Test Badge " + num, + "course_id": null, + "description": "Yay! It's a test badge.", + "criteria": "https://example.com/syllabus", + "image_url": "http://localhost:8000/media/badge_classes/test_lMB9bRw.png" + }, + "image_url": "http://example.com/image.png", + "assertion_url": "http://example.com/example.json", + "created_at": "2015-12-03T16:25:57.676113Z" + }; + } + + _.each(_.range(0, 10), function(i) { + firstPageBadges.results.push(makeBadge(i)); + }); + + _.each(_.range(10, 20), function(i) { + secondPageBadges.results.push(makeBadge(i)); + }); + + _.each(_.range(20, 30), function(i) { + thirdPageBadges.results.push(makeBadge(i)); + }); + + var emptyBadges = { + "count": 0, + "previous": null, + "num_pages": 1, + "results": [] + }; + return { expectLimitedProfileSectionsAndFieldsToBeRendered: expectLimitedProfileSectionsAndFieldsToBeRendered, expectProfileSectionsAndFieldsToBeRendered: expectProfileSectionsAndFieldsToBeRendered, - expectProfileSectionsNotToBeRendered: expectProfileSectionsNotToBeRendered + expectProfileSectionsNotToBeRendered: expectProfileSectionsNotToBeRendered, + expectTabbedViewToBeHidden: expectTabbedViewToBeHidden, expectTabbedViewToBeShown: expectTabbedViewToBeShown, + expectBadgesDisplayed: expectBadgesDisplayed, expectBadgesHidden: expectBadgesHidden, + firstPageBadges: firstPageBadges, secondPageBadges: secondPageBadges, thirdPageBadges: thirdPageBadges, + emptyBadges: emptyBadges, expectPage: expectPage }; }); diff --git a/lms/static/js/spec/student_profile/learner_profile_factory_spec.js b/lms/static/js/spec/student_profile/learner_profile_factory_spec.js index 638a51d..8eea52a 100644 --- a/lms/static/js/spec/student_profile/learner_profile_factory_spec.js +++ b/lms/static/js/spec/student_profile/learner_profile_factory_spec.js @@ -22,10 +22,15 @@ define(['backbone', 'jquery', 'underscore', 'common/js/spec_helpers/ajax_helpers loadFixtures('js/fixtures/student_profile/student_profile.html'); }); + afterEach(function () { + Backbone.history.stop(); + }); + var createProfilePage = function(ownProfile, options) { return new LearnerProfilePage({ 'accounts_api_url': Helpers.USER_ACCOUNTS_API_URL, 'preferences_api_url': Helpers.USER_PREFERENCES_API_URL, + 'badges_api_url': Helpers.BADGES_API_URL, 'own_profile': ownProfile, 'account_settings_page_url': Helpers.USER_ACCOUNTS_API_URL, 'country_options': Helpers.FIELD_OPTIONS, @@ -37,6 +42,7 @@ define(['backbone', 'jquery', 'underscore', 'common/js/spec_helpers/ajax_helpers 'profile_image_remove_url': Helpers.IMAGE_REMOVE_API_URL, 'default_visibility': 'all_users', 'platform_name': 'edX', + 'find_courses_url': '/courses/', 'account_settings_data': Helpers.createAccountSettingsData(options), 'preferences_data': Helpers.createUserPreferencesData() }); @@ -62,6 +68,165 @@ define(['backbone', 'jquery', 'underscore', 'common/js/spec_helpers/ajax_helpers LearnerProfileHelpers.expectLimitedProfileSectionsAndFieldsToBeRendered(learnerProfileView); }); + it("doesn't show the mode toggle if badges are disabled", function() { + + requests = AjaxHelpers.requests(this); + + var context = createProfilePage(true, {accomplishments_shared: false}), + tabbedView = context.learnerProfileView.tabbedView, + learnerProfileView = context.learnerProfileView; + + LearnerProfileHelpers.expectTabbedViewToBeHidden(requests, tabbedView); + LearnerProfileHelpers.expectBadgesHidden(learnerProfileView); + }); + + it("doesn't show the mode toggle if badges fail to fetch", function() { + + requests = AjaxHelpers.requests(this); + + var context = createProfilePage(true, {accomplishments_shared: false}), + tabbedView = context.learnerProfileView.tabbedView, + learnerProfileView = context.learnerProfileView; + + LearnerProfileHelpers.expectTabbedViewToBeHidden(requests, tabbedView); + LearnerProfileHelpers.expectBadgesHidden(learnerProfileView); + }); + + it("renders the mode toggle if there are badges", function() { + + requests = AjaxHelpers.requests(this); + + var context = createProfilePage(true, {accomplishments_shared: true}), + tabbedView = context.learnerProfileView.tabbedView; + + AjaxHelpers.expectRequest(requests, 'POST', '/event'); + AjaxHelpers.respondWithError(requests, 404); + AjaxHelpers.respondWithJson(requests, LearnerProfileHelpers.firstPageBadges); + + LearnerProfileHelpers.expectTabbedViewToBeShown(tabbedView); + }); + + it("renders the mode toggle if badges enabled but none exist", function() { + + requests = AjaxHelpers.requests(this); + + var context = createProfilePage(true, {accomplishments_shared: true}), + tabbedView = context.learnerProfileView.tabbedView; + + AjaxHelpers.expectRequest(requests, 'POST', '/event'); + AjaxHelpers.respondWithError(requests, 404); + AjaxHelpers.respondWithJson(requests, LearnerProfileHelpers.emptyBadges); + + LearnerProfileHelpers.expectTabbedViewToBeShown(tabbedView); + }); + + it("displays the badges when the accomplishments toggle is selected", function () { + + requests = AjaxHelpers.requests(this); + + var context = createProfilePage(true, {accomplishments_shared: true}), + learnerProfileView = context.learnerProfileView, + tabbedView = learnerProfileView.tabbedView; + + AjaxHelpers.expectRequest(requests, 'POST', '/event'); + AjaxHelpers.respondWithError(requests, 404); + AjaxHelpers.respondWithJson(requests, LearnerProfileHelpers.secondPageBadges); + + LearnerProfileHelpers.expectBadgesHidden(learnerProfileView); + tabbedView.$el.find('[data-url="accomplishments"]').click(); + LearnerProfileHelpers.expectBadgesDisplayed(learnerProfileView, 10, false); + tabbedView.$el.find('[data-url="about_me"]').click(); + LearnerProfileHelpers.expectBadgesHidden(learnerProfileView); + }); + + it("displays a placeholder on the last page of badges", function () { + + requests = AjaxHelpers.requests(this); + + var context = createProfilePage(true, {accomplishments_shared: true}), + learnerProfileView = context.learnerProfileView, + tabbedView = learnerProfileView.tabbedView; + + AjaxHelpers.expectRequest(requests, 'POST', '/event'); + AjaxHelpers.respondWithError(requests, 404); + AjaxHelpers.respondWithJson(requests, LearnerProfileHelpers.thirdPageBadges); + + LearnerProfileHelpers.expectBadgesHidden(learnerProfileView); + tabbedView.$el.find('[data-url="accomplishments"]').click(); + LearnerProfileHelpers.expectBadgesDisplayed(learnerProfileView, 10, true); + tabbedView.$el.find('[data-url="about_me"]').click(); + LearnerProfileHelpers.expectBadgesHidden(learnerProfileView); + }); + + it("displays a placeholder when the accomplishments toggle is selected and no badges exist", function () { + + requests = AjaxHelpers.requests(this); + + var context = createProfilePage(true, {accomplishments_shared: true}), + learnerProfileView = context.learnerProfileView, + tabbedView = learnerProfileView.tabbedView; + + AjaxHelpers.expectRequest(requests, 'POST', '/event'); + AjaxHelpers.respondWithError(requests, 404); + AjaxHelpers.respondWithJson(requests, LearnerProfileHelpers.emptyBadges); + + LearnerProfileHelpers.expectBadgesHidden(learnerProfileView); + tabbedView.$el.find('[data-url="accomplishments"]').click(); + LearnerProfileHelpers.expectBadgesDisplayed(learnerProfileView, 0, true); + tabbedView.$el.find('[data-url="about_me"]').click(); + LearnerProfileHelpers.expectBadgesHidden(learnerProfileView); + }); + + it("shows a paginated list of badges", function() { + requests = AjaxHelpers.requests(this); + + var context = createProfilePage(true, {accomplishments_shared: true}), + learnerProfileView = context.learnerProfileView, + tabbedView = learnerProfileView.tabbedView; + + AjaxHelpers.expectRequest(requests, 'POST', '/event'); + AjaxHelpers.respondWithError(requests, 404); + AjaxHelpers.respondWithJson(requests, LearnerProfileHelpers.firstPageBadges); + + tabbedView.$el.find('[data-url="accomplishments"]').click(); + LearnerProfileHelpers.expectBadgesDisplayed(learnerProfileView, 10, false); + LearnerProfileHelpers.expectPage(learnerProfileView, LearnerProfileHelpers.firstPageBadges); + }); + + it("allows forward and backward navigation of badges", function () { + requests = AjaxHelpers.requests(this); + + var context = createProfilePage(true, {accomplishments_shared: true}), + learnerProfileView = context.learnerProfileView, + tabbedView = learnerProfileView.tabbedView, + badgeListContainer = context.badgeListContainer; + + AjaxHelpers.expectRequest(requests, 'POST', '/event'); + AjaxHelpers.respondWithError(requests, 404); + AjaxHelpers.respondWithJson(requests, LearnerProfileHelpers.firstPageBadges); + + tabbedView.$el.find('[data-url="accomplishments"]').click(); + + badgeListContainer.$el.find('.next-page-link').click(); + AjaxHelpers.respondWithJson(requests, LearnerProfileHelpers.secondPageBadges); + LearnerProfileHelpers.expectPage(learnerProfileView, LearnerProfileHelpers.secondPageBadges); + + badgeListContainer.$el.find('.next-page-link').click(); + AjaxHelpers.respondWithJson(requests, LearnerProfileHelpers.thirdPageBadges); + LearnerProfileHelpers.expectBadgesDisplayed(learnerProfileView, 10, true); + LearnerProfileHelpers.expectPage(learnerProfileView, LearnerProfileHelpers.thirdPageBadges); + + badgeListContainer.$el.find('.previous-page-link').click(); + AjaxHelpers.respondWithJson(requests, LearnerProfileHelpers.secondPageBadges); + LearnerProfileHelpers.expectPage(learnerProfileView, LearnerProfileHelpers.secondPageBadges); + LearnerProfileHelpers.expectBadgesDisplayed(learnerProfileView, 10, false); + + badgeListContainer.$el.find('.previous-page-link').click(); + AjaxHelpers.respondWithJson(requests, LearnerProfileHelpers.firstPageBadges); + LearnerProfileHelpers.expectPage(learnerProfileView, LearnerProfileHelpers.firstPageBadges); + }); + + it("renders the limited profile for under 13 users", function() { var context = createProfilePage( diff --git a/lms/static/js/spec/student_profile/learner_profile_view_spec.js b/lms/static/js/spec/student_profile/learner_profile_view_spec.js index 45cee67..263a8e5 100644 --- a/lms/static/js/spec/student_profile/learner_profile_view_spec.js +++ b/lms/static/js/spec/student_profile/learner_profile_view_spec.js @@ -6,12 +6,14 @@ define(['backbone', 'jquery', 'underscore', 'common/js/spec_helpers/ajax_helpers 'js/student_account/models/user_preferences_model', 'js/student_profile/views/learner_profile_fields', 'js/student_profile/views/learner_profile_view', + 'js/student_profile/views/badge_list_container', 'js/student_account/views/account_settings_fields', + 'common/js/components/collections/paging_collection', 'js/views/message_banner' ], function (Backbone, $, _, AjaxHelpers, TemplateHelpers, Helpers, LearnerProfileHelpers, FieldViews, UserAccountModel, AccountPreferencesModel, LearnerProfileFields, LearnerProfileView, - AccountSettingsFieldViews, MessageBannerView) { + BadgeListContainer, AccountSettingsFieldViews, PagingCollection, MessageBannerView) { 'use strict'; describe("edx.user.LearnerProfileView", function () { @@ -106,6 +108,15 @@ define(['backbone', 'jquery', 'underscore', 'common/js/spec_helpers/ajax_helpers }) ]; + var badgeCollection = new PagingCollection(); + badgeCollection.url = Helpers.BADGES_API_URL; + + var badgeListContainer = new BadgeListContainer({ + 'attributes': {'class': 'badge-set-display'}, + 'collection': badgeCollection, + 'find_courses_url': Helpers.FIND_COURSES_URL + }); + return new LearnerProfileView( { el: $('.wrapper-profile'), @@ -117,7 +128,8 @@ define(['backbone', 'jquery', 'underscore', 'common/js/spec_helpers/ajax_helpers usernameFieldView: usernameFieldView, profileImageFieldView: profileImageFieldView, sectionOneFieldViews: sectionOneFieldViews, - sectionTwoFieldViews: sectionTwoFieldViews + sectionTwoFieldViews: sectionTwoFieldViews, + badgeListContainer: badgeListContainer }); }; @@ -125,6 +137,10 @@ define(['backbone', 'jquery', 'underscore', 'common/js/spec_helpers/ajax_helpers loadFixtures('js/fixtures/student_profile/student_profile.html'); }); + afterEach(function () { + Backbone.history.stop(); + }); + it("shows loading error correctly", function() { var learnerProfileView = createLearnerProfileView(false, 'all_users'); @@ -189,5 +205,6 @@ define(['backbone', 'jquery', 'underscore', 'common/js/spec_helpers/ajax_helpers Helpers.expectLoadingErrorIsVisible(learnerProfileView, false); LearnerProfileHelpers.expectLimitedProfileSectionsAndFieldsToBeRendered(learnerProfileView, true); }); + }); }); diff --git a/lms/static/js/student_account/models/user_account_model.js b/lms/static/js/student_account/models/user_account_model.js index fbd040a..f08b602 100644 --- a/lms/static/js/student_account/models/user_account_model.js +++ b/lms/static/js/student_account/models/user_account_model.js @@ -23,6 +23,7 @@ language_proficiencies: [], requires_parental_consent: true, profile_image: null, + accomplishments_shared: false, default_public_account_fields: [] }, diff --git a/lms/static/js/student_profile/models/badges_model.js b/lms/static/js/student_profile/models/badges_model.js new file mode 100644 index 0000000..8b12782 --- /dev/null +++ b/lms/static/js/student_profile/models/badges_model.js @@ -0,0 +1,8 @@ +;(function (define) { + 'use strict'; + define(['backbone'], function(Backbone) { + + var BadgesModel = Backbone.Model.extend({}); + return BadgesModel; + }); +}).call(this, define || RequireJS.define); diff --git a/lms/static/js/student_profile/views/badge_list_container.js b/lms/static/js/student_profile/views/badge_list_container.js new file mode 100644 index 0000000..e53bce1 --- /dev/null +++ b/lms/static/js/student_profile/views/badge_list_container.js @@ -0,0 +1,23 @@ +;(function (define, undefined) { + 'use strict'; + define([ + 'gettext', 'jquery', 'underscore', 'common/js/components/views/paginated_view', + 'js/student_profile/views/badge_view', 'js/student_profile/views/badge_list_view', + 'text!templates/student_profile/badge_list.underscore'], + function (gettext, $, _, PaginatedView, BadgeView, BadgeListView, BadgeListTemplate) { + var BadgeListContainer = PaginatedView.extend({ + initialize: function (options) { + BadgeListContainer.__super__.initialize.call(this, options); + this.listView.find_courses_url = options.find_courses_url; + }, + type: 'badge', + itemViewClass: BadgeView, + listViewClass: BadgeListView, + viewTemplate: BadgeListTemplate, + isZeroIndexed: true, + paginationLabel: gettext("Accomplishments Pagination") + }); + + return BadgeListContainer; + }); +}).call(this, define || RequireJS.define); diff --git a/lms/static/js/student_profile/views/badge_list_view.js b/lms/static/js/student_profile/views/badge_list_view.js new file mode 100644 index 0000000..6112e5a --- /dev/null +++ b/lms/static/js/student_profile/views/badge_list_view.js @@ -0,0 +1,40 @@ +;(function (define, undefined) { + 'use strict'; + define([ + 'gettext', 'jquery', 'underscore', 'common/js/components/views/list', 'js/student_profile/views/badge_view', + 'text!templates/student_profile/badge_placeholder.underscore'], + function (gettext, $, _, ListView, BadgeView, badgePlaceholder) { + var BadgeListView = ListView.extend({ + tagName: 'div', + template: _.template(badgePlaceholder), + renderCollection: function () { + this.$el.empty(); + var self = this; + var row; + // Split into two columns. + this.collection.each(function (badge, index) { + if (index % 2 === 0) { + row = $('<div class="row">'); + this.$el.append(row); + } + var item = new BadgeView({model: badge}).render().el; + row.append(item); + this.itemViews.push(item); + }, this); + // Placeholder must always be at the end, and may need a new row. + if (!this.collection.hasNextPage()) { + // find_courses_url set by BadgeListContainer during initialization. + var placeholder = this.template({find_courses_url: self.find_courses_url}); + if (this.collection.length % 2 === 0) { + row = $('<div class="row">'); + this.$el.append(row); + } + row.append(placeholder); + } + return this; + } + }); + + return BadgeListView; + }); +}).call(this, define || RequireJS.define); diff --git a/lms/static/js/student_profile/views/badge_view.js b/lms/static/js/student_profile/views/badge_view.js new file mode 100644 index 0000000..1c1a673 --- /dev/null +++ b/lms/static/js/student_profile/views/badge_view.js @@ -0,0 +1,23 @@ +;(function (define, undefined) { + 'use strict'; + define([ + 'gettext', 'jquery', 'underscore', 'backbone', 'moment', 'text!templates/student_profile/badge.underscore'], + function (gettext, $, _, Backbone, Moment, badgeTemplate) { + + var BadgeView = Backbone.View.extend({ + attributes: { + 'class': 'badge-display' + }, + template: _.template(badgeTemplate), + render: function () { + var context = _.extend(this.options.model.toJSON(), { + 'created': new Moment(this.options.model.toJSON().created) + }); + this.$el.html(this.template(context)); + return this; + } + }); + + return BadgeView; + }); +}).call(this, define || RequireJS.define); diff --git a/lms/static/js/student_profile/views/learner_profile_factory.js b/lms/static/js/student_profile/views/learner_profile_factory.js index a727532..4ac76bc 100644 --- a/lms/static/js/student_profile/views/learner_profile_factory.js +++ b/lms/static/js/student_profile/views/learner_profile_factory.js @@ -7,11 +7,15 @@ 'js/views/fields', 'js/student_profile/views/learner_profile_fields', 'js/student_profile/views/learner_profile_view', + 'js/student_profile/models/badges_model', + 'common/js/components/collections/paging_collection', + 'js/student_profile/views/badge_list_container', 'js/student_account/views/account_settings_fields', 'js/views/message_banner', 'string_utils' ], function (gettext, $, _, Backbone, Logger, AccountSettingsModel, AccountPreferencesModel, FieldsView, - LearnerProfileFieldsView, LearnerProfileView, AccountSettingsFieldViews, MessageBannerView) { + LearnerProfileFieldsView, LearnerProfileView, BadgeModel, PagingCollection, BadgeListContainer, + AccountSettingsFieldViews, MessageBannerView) { return function (options) { @@ -121,6 +125,15 @@ }) ]; + var badgeCollection = new PagingCollection(); + badgeCollection.url = options.badges_api_url; + + var badgeListContainer = new BadgeListContainer({ + 'attributes': {'class': 'badge-set-display'}, + 'collection': badgeCollection, + 'find_courses_url': options.find_courses_url + }); + var learnerProfileView = new LearnerProfileView({ el: learnerProfileElement, ownProfile: options.own_profile, @@ -131,7 +144,8 @@ profileImageFieldView: profileImageFieldView, usernameFieldView: usernameFieldView, sectionOneFieldViews: sectionOneFieldViews, - sectionTwoFieldViews: sectionTwoFieldViews + sectionTwoFieldViews: sectionTwoFieldViews, + badgeListContainer: badgeListContainer }); var getProfileVisibility = function() { @@ -164,7 +178,8 @@ return { accountSettingsModel: accountSettingsModel, accountPreferencesModel: accountPreferencesModel, - learnerProfileView: learnerProfileView + learnerProfileView: learnerProfileView, + badgeListContainer: badgeListContainer }; }; }); diff --git a/lms/static/js/student_profile/views/learner_profile_view.js b/lms/static/js/student_profile/views/learner_profile_view.js index b652c4f..5ffbddb 100644 --- a/lms/static/js/student_profile/views/learner_profile_view.js +++ b/lms/static/js/student_profile/views/learner_profile_view.js @@ -1,15 +1,26 @@ ;(function (define, undefined) { 'use strict'; define([ - 'gettext', 'jquery', 'underscore', 'backbone', 'text!templates/student_profile/learner_profile.underscore'], - function (gettext, $, _, Backbone, learnerProfileTemplate) { + 'gettext', 'jquery', 'underscore', 'backbone', + 'common/js/components/views/tabbed_view', + 'js/student_profile/views/section_two_tab', + 'text!templates/student_profile/learner_profile.underscore'], + function (gettext, $, _, Backbone, TabbedView, SectionTwoTab, learnerProfileTemplate) { var LearnerProfileView = Backbone.View.extend({ initialize: function () { _.bindAll(this, 'showFullProfile', 'render', 'renderFields', 'showLoadingError'); this.listenTo(this.options.preferencesModel, "change:" + 'account_privacy', this.render); + var Router = Backbone.Router.extend({ + routes: {":about_me": "loadTab", ":accomplishments": "loadTab"} + }); + + this.router = new Router(); + this.firstRender = true; }, + + template: _.template(learnerProfileTemplate), showFullProfile: function () { var isAboveMinimumAge = this.options.accountSettingsModel.isAboveMinimumAge(); @@ -20,13 +31,71 @@ } }, + setActiveTab: function(tab) { + // This tab may not actually exist. + if (this.tabbedView.getTabMeta(tab).tab) { + this.tabbedView.setActiveTab(tab); + } + }, + render: function () { - this.$el.html(_.template(learnerProfileTemplate)({ - username: this.options.accountSettingsModel.get('username'), - ownProfile: this.options.ownProfile, - showFullProfile: this.showFullProfile() + var self = this; + + this.sectionTwoView = new SectionTwoTab({ + viewList: this.options.sectionTwoFieldViews, + showFullProfile: this.showFullProfile, + ownProfile: this.options.ownProfile + }); + + var tabs = [ + {view: this.sectionTwoView, title: gettext("About Me"), url: "about_me"} + ]; + + this.$el.html(this.template({ + username: self.options.accountSettingsModel.get('username'), + ownProfile: self.options.ownProfile, + showFullProfile: self.showFullProfile() })); this.renderFields(); + + if (this.showFullProfile() && (this.options.accountSettingsModel.get('accomplishments_shared'))) { + tabs.push({ + view: this.options.badgeListContainer, + title: gettext("Accomplishments"), + url: "accomplishments" + }); + this.options.badgeListContainer.collection.fetch().done(function () { + self.options.badgeListContainer.render(); + }); + } + this.tabbedView = new TabbedView({ + tabs: tabs, + router: this.router, + viewLabel: gettext("Profile") + }); + + this.tabbedView.render(); + + if (tabs.length === 1) { + // If the tab is unambiguous, don't display the tab interface. + this.tabbedView.$el.find('.page-content-nav').hide(); + } + + this.$el.find('.account-settings-container').append(this.tabbedView.el); + + if (this.firstRender) { + this.router.on("route:loadTab", _.bind(this.setActiveTab, this)); + Backbone.history.start(); + this.firstRender = false; + // Load from history. + this.router.navigate((Backbone.history.getFragment() || 'about_me'), {trigger: true}); + } else { + // Restart the router so the tab will be brought up anew. + Backbone.history.stop(); + Backbone.history.start(); + } + + return this; }, @@ -54,9 +123,6 @@ view.$('.profile-section-one-fields').append(fieldView.render().el); }); - _.each(this.options.sectionTwoFieldViews, function (fieldView) { - view.$('.profile-section-two-fields').append(fieldView.render().el); - }); } }, diff --git a/lms/static/js/student_profile/views/section_two_tab.js b/lms/static/js/student_profile/views/section_two_tab.js new file mode 100644 index 0000000..8986811 --- /dev/null +++ b/lms/static/js/student_profile/views/section_two_tab.js @@ -0,0 +1,30 @@ +;(function (define, undefined) { + 'use strict'; + define([ + 'gettext', 'jquery', 'underscore', 'backbone', 'text!templates/student_profile/section_two.underscore'], + function (gettext, $, _, Backbone, sectionTwoTemplate) { + + var SectionTwoTab = Backbone.View.extend({ + attributes: { + 'class': 'wrapper-profile-section-two' + }, + template: _.template(sectionTwoTemplate), + render: function () { + var self = this; + var showFullProfile = this.options.showFullProfile(); + this.$el.html(this.template({ + ownProfile: self.options.ownProfile, + showFullProfile: showFullProfile + })); + if (showFullProfile) { + _.each(this.options.viewList, function (fieldView) { + self.$el.find('.field-container').append(fieldView.render().el); + }); + } + return this; + } + }); + + return SectionTwoTab; + }); +}).call(this, define || RequireJS.define); diff --git a/lms/static/js_test.yml b/lms/static/js_test.yml index c82b773..1e7ca63 100644 --- a/lms/static/js_test.yml +++ b/lms/static/js_test.yml @@ -107,7 +107,6 @@ fixture_paths: - templates/verify_student - templates/file-upload.underscore - templates/components/header - - templates/components/tabbed - templates/components/card - templates/financial-assistance/ - js/fixtures/edxnotes diff --git a/lms/static/sass/_build-lms.scss b/lms/static/sass/_build-lms.scss index b749e0f..386ce01 100644 --- a/lms/static/sass/_build-lms.scss +++ b/lms/static/sass/_build-lms.scss @@ -15,6 +15,7 @@ // base - elements @import 'elements/typography'; @import 'elements/controls'; +@import 'elements/navigation'; @import 'elements/pagination'; @import 'elements/creative-commons'; @import 'elements/program-card'; diff --git a/lms/static/sass/elements/_navigation.scss b/lms/static/sass/elements/_navigation.scss index ef53e15..40087c3 100644 --- a/lms/static/sass/elements/_navigation.scss +++ b/lms/static/sass/elements/_navigation.scss @@ -115,3 +115,39 @@ @include right($baseline*2.50); } } + +%button-reset { + box-shadow: none; + border: none; + border-radius: 0; + background: transparent none; + font-family: inherit; + font-size: inherit; + font-weight: inherit; +} + +%page-content-nav { + margin-bottom: $baseline; + border-bottom: 3px solid $gray-l5; + + .nav-item { + @extend %button-reset; + display: inline-block; + margin-bottom: -3px; // to match the border + border-bottom: 3px solid $gray-l5; + padding: ($baseline*.75); + color: $gray-d2; + + &.is-active { + border-bottom: 3px solid $gray-d2; + color: $gray-d2; + } + + // STATE: hover and focus + &:hover, + &:focus { + border-bottom: 3px solid $link-color; + color: $link-color; + } + } +} diff --git a/lms/static/sass/views/_learner-profile.scss b/lms/static/sass/views/_learner-profile.scss index ac44ea3..725cc52 100644 --- a/lms/static/sass/views/_learner-profile.scss +++ b/lms/static/sass/views/_learner-profile.scss @@ -189,7 +189,7 @@ .u-field-username { input[type="text"] { - font-weight: 600; + font-weight: 600; } .u-field-value { @@ -231,6 +231,7 @@ } .wrapper-profile-section-two { + padding-top: 1em; width: flex-grid(8, 12); } @@ -259,9 +260,9 @@ width: 100%; textarea { - width: 100%; - background-color: transparent; - white-space: pre-line; + width: 100%; + background-color: transparent; + white-space: pre-line; } a { @@ -278,10 +279,10 @@ padding: $baseline; border: 2px dashed $gray-l3; i { - font-size: 12px; - @include padding-right(5px); - vertical-align: middle; - color: $gray; + font-size: 12px; + @include padding-right(5px); + vertical-align: middle; + color: $gray; } .u-field-title { width: 100%; @@ -300,8 +301,75 @@ border: 2px dashed $link-color; .u-field-title, i { - color: $link-color; + color: $link-color; + } + } + } + + .badge-paging-header { + padding-top: $baseline; + } + + .page-content-nav { + @extend %page-content-nav; + } + + .badge-set-display { + @extend .container; + padding: 0 0; + .badge-display { + width: 50%; + display: inline-block; + vertical-align: top; + padding: 2em 0; + .badge-image-container { + padding-right: $baseline; + margin-left: 1em; + width: 20%; + vertical-align: top; + display: inline-block; + img.badge { + width: 100%; + } + .accomplishment-placeholder { + border: 4px dotted $gray-l4; + border-radius: 50%; + display: block; + width: 100%; + padding-bottom: 100%; + } } + .badge-details { + @extend %t-copy-sub1; + @extend %t-regular; + max-width: 70%; + display: inline-block; + color: $gray-d1; + .badge-name { + @extend %t-strong; + @extend %t-copy-base; + color: $gray-d3; + } + .badge-description { + padding-bottom: $baseline; + line-height: 1.5em; + } + .badge-date-stamp{ + @extend %t-copy-sub1; + } + .find-button-container { + border: 1px solid $blue-l1; + padding: ($baseline / 2) $baseline ($baseline / 2) $baseline; + display: inline-block; + border-radius: 5px; + font-weight: bold; + color: $blue-s3; + } + } + } + .badge-placeholder { + background-color: $gray-l7; + box-shadow: inset 0 0 4px 0 $gray-l4; } } } diff --git a/lms/static/sass/views/_teams.scss b/lms/static/sass/views/_teams.scss index 0b77c33..dda2531 100644 --- a/lms/static/sass/views/_teams.scss +++ b/lms/static/sass/views/_teams.scss @@ -19,17 +19,6 @@ color: inherit; } - %button-reset { - box-shadow: none; - border: none; - border-radius: 0; - background-image: none; - background-color: transparent; - font-family: inherit; - font-size: inherit; - font-weight: inherit; - } - // layout .page-header { @include clearfix(); @@ -147,29 +136,7 @@ } .page-content-nav { - margin-bottom: $baseline; - border-bottom: 3px solid $gray-l5; - - .nav-item { - @extend %button-reset; - display: inline-block; - margin-bottom: -3px; // to match the border - border-bottom: 3px solid $gray-l5; - padding: ($baseline*.75); - color: $gray-d2; - - &.is-active { - border-bottom: 3px solid $gray-d2; - color: $gray-d2; - } - - // STATE: hover and focus - &:hover, - &:focus { - border-bottom: 3px solid $link-color; - color: $link-color; - } - } + @extend %page-content-nav; } .intro { diff --git a/lms/templates/components/tabbed/tab.underscore b/lms/templates/components/tabbed/tab.underscore deleted file mode 100644 index d7c5e37..0000000 --- a/lms/templates/components/tabbed/tab.underscore +++ /dev/null @@ -1 +0,0 @@ -<button class="nav-item tab" data-url="<%= url %>" data-index="<%= index %>" is-active="false" aria-expanded="false" aria-controls="<%= tabPanelId %>"><%= title %></button> diff --git a/lms/templates/components/tabbed/tabbed_view.underscore b/lms/templates/components/tabbed/tabbed_view.underscore deleted file mode 100644 index 669fa98..0000000 --- a/lms/templates/components/tabbed/tabbed_view.underscore +++ /dev/null @@ -1,4 +0,0 @@ -<nav class="page-content-nav" aria-label="Teams"></nav> -<div class="page-content-main"> - <div class="tabs"></div> -</div> diff --git a/lms/templates/components/tabbed/tabpanel.underscore b/lms/templates/components/tabbed/tabpanel.underscore deleted file mode 100644 index c1104c1..0000000 --- a/lms/templates/components/tabbed/tabpanel.underscore +++ /dev/null @@ -1,3 +0,0 @@ -<div class="tabpanel is-hidden" id="<%= tabId %>" aria-expanded="false"> - <div class="sr-is-focusable <%= tabId %>" tabindex="-1"></div> -</div> diff --git a/lms/templates/student_profile/badge.underscore b/lms/templates/student_profile/badge.underscore new file mode 100644 index 0000000..00b7f33 --- /dev/null +++ b/lms/templates/student_profile/badge.underscore @@ -0,0 +1,16 @@ +<div class="badge-image-container"> + <img class="badge" src="<%- image_url %>" alt=""/> +</div> +<div class="badge-details"> + <div class="badge-name"><%- badge_class.display_name %></div> + <p class="badge-description"><%- badge_class.description %></p> + <div class="badge-date-stamp"> + <%- + interpolate( + // Translators: Date stamp for earned badges. Example: Earned December 3, 2015. + gettext('Earned %(created)s.'), + {created: created.format('LL')}, + true + ) + %></div> +</div> diff --git a/lms/templates/student_profile/badge_list.underscore b/lms/templates/student_profile/badge_list.underscore new file mode 100644 index 0000000..55f161b --- /dev/null +++ b/lms/templates/student_profile/badge_list.underscore @@ -0,0 +1,4 @@ +<div class="sr-is-focusable sr-<%= type %>-view" tabindex="-1"></div> +<div class="<%= type %>-paging-header"></div> +<ul class="<%= type %>-list cards-list"></ul> +<div class="<%= type %>-paging-footer"></div> diff --git a/lms/templates/student_profile/badge_placeholder.underscore b/lms/templates/student_profile/badge_placeholder.underscore new file mode 100644 index 0000000..fc24899 --- /dev/null +++ b/lms/templates/student_profile/badge_placeholder.underscore @@ -0,0 +1,10 @@ +<div class="badge-display badge-placeholder"> + <div class="badge-image-container"> + <span class="accomplishment-placeholder" aria-hidden="true"> + </div> + <div class="badge-details"> + <div class="badge-name"><%- gettext("What's Your Next Accomplishment?") %></div> + <p class="badge-description"><%- gettext('Start working toward your next learning goal.') %></p> + <a class="find-course" href="<%= find_courses_url %>"><span class="find-button-container"><%- gettext('Find a course') %></span></a> + </div> +</div> diff --git a/lms/templates/student_profile/learner_profile.underscore b/lms/templates/student_profile/learner_profile.underscore index 9cb50b8..a8050a6 100644 --- a/lms/templates/student_profile/learner_profile.underscore +++ b/lms/templates/student_profile/learner_profile.underscore @@ -1,28 +1,15 @@ <div class="profile <%- ownProfile ? 'profile-self' : 'profile-other' %>"> <div class="wrapper-profile-field-account-privacy"></div> - <div class="wrapper-profile-sections account-settings-container"> <div class="wrapper-profile-section-one"> <div class="profile-image-field"> </div> - <div class="profile-section-one-fields"> </div> </div> <div class="ui-loading-error is-hidden"> <i class="fa fa-exclamation-triangle message-error" aria-hidden=true></i> - <span class="copy"><%- gettext("An error occurred. Please reload the page.") %></span> - </div> - <div class="wrapper-profile-section-two"> - <div class="profile-section-two-fields"> - <% if (!showFullProfile) { %> - <% if(ownProfile) { %> - <span class="profile-private--message" tabindex="0"><%- gettext("You are currently sharing a limited profile.") %></span> - <% } else { %> - <span class="profile-private--message" tabindex="0"><%- gettext("This edX learner is currently sharing a limited profile.") %></span> - <% } %> - <% } %> - </div> + <span class="copy"><%- gettext("An error occurred. Try loading the page again.") %></span> </div> </div> </div> diff --git a/lms/templates/student_profile/section_two.underscore b/lms/templates/student_profile/section_two.underscore new file mode 100644 index 0000000..5c9794d --- /dev/null +++ b/lms/templates/student_profile/section_two.underscore @@ -0,0 +1,10 @@ +<div class="profile-section-two-fields"> + <div class="field-container"></div> + <% if (!showFullProfile) { %> + <% if(ownProfile) { %> + <span class="profile-private--message" tabindex="0"><%- gettext("You are currently sharing a limited profile.") %></span> + <% } else { %> + <span class="profile-private--message" tabindex="0"><%- gettext("This learner is currently sharing a limited profile.") %></span> + <% } %> + <% } %> +</div> \ No newline at end of file diff --git a/lms/urls.py b/lms/urls.py index 9b12613..cec1568 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -138,7 +138,7 @@ if settings.FEATURES["ENABLE_MOBILE_REST_API"]: if settings.FEATURES["ENABLE_OPENBADGES"]: urlpatterns += ( - url(r'^api/badges/v1/', include('badges.api.urls')), + url(r'^api/badges/v1/', include('badges.api.urls', app_name="badges", namespace="badges_api")), ) js_info_dict = { diff --git a/openedx/core/djangoapps/user_api/accounts/api.py b/openedx/core/djangoapps/user_api/accounts/api.py index fd9f6b8..61c7adf 100644 --- a/openedx/core/djangoapps/user_api/accounts/api.py +++ b/openedx/core/djangoapps/user_api/accounts/api.py @@ -24,19 +24,21 @@ from ..errors import ( ) from ..forms import PasswordResetFormNoActive from ..helpers import intercept_errors -from ..models import UserPreference from . import ( - ACCOUNT_VISIBILITY_PREF_KEY, PRIVATE_VISIBILITY, EMAIL_MIN_LENGTH, EMAIL_MAX_LENGTH, PASSWORD_MIN_LENGTH, PASSWORD_MAX_LENGTH, USERNAME_MIN_LENGTH, USERNAME_MAX_LENGTH ) from .serializers import ( AccountLegacyProfileSerializer, AccountUserSerializer, - UserReadOnlySerializer + UserReadOnlySerializer, _visible_fields # pylint: disable=invalid-name ) +# Public access point for this function. +visible_fields = _visible_fields + + @intercept_errors(UserAPIInternalError, ignore_errors=[UserAPIRequestError]) def get_account_settings(request, username=None, configuration=None, view=None): """Returns account information for a user serialized as JSON. diff --git a/openedx/core/djangoapps/user_api/accounts/serializers.py b/openedx/core/djangoapps/user_api/accounts/serializers.py index c43b21e..b9952a8 100644 --- a/openedx/core/djangoapps/user_api/accounts/serializers.py +++ b/openedx/core/djangoapps/user_api/accounts/serializers.py @@ -5,15 +5,15 @@ from rest_framework import serializers from django.contrib.auth.models import User from django.conf import settings from django.core.urlresolvers import reverse -from openedx.core.djangoapps.user_api.accounts import NAME_MIN_LENGTH -from openedx.core.djangoapps.user_api.serializers import ReadOnlyFieldsSerializerMixin -from student.models import UserProfile, LanguageProficiency -from ..models import UserPreference -from .image_helpers import get_profile_image_urls_for_user from . import ( - ACCOUNT_VISIBILITY_PREF_KEY, ALL_USERS_VISIBILITY, PRIVATE_VISIBILITY, + NAME_MIN_LENGTH, ACCOUNT_VISIBILITY_PREF_KEY, PRIVATE_VISIBILITY, + ALL_USERS_VISIBILITY, ) +from openedx.core.djangoapps.user_api.models import UserPreference +from openedx.core.djangoapps.user_api.serializers import ReadOnlyFieldsSerializerMixin +from student.models import UserProfile, LanguageProficiency +from .image_helpers import get_profile_image_urls_for_user PROFILE_IMAGE_KEY_PREFIX = 'image_url' @@ -52,7 +52,7 @@ class UserReadOnlySerializer(serializers.Serializer): self.configuration = settings.ACCOUNT_VISIBILITY_CONFIGURATION # Don't pass the 'custom_fields' arg up to the superclass - self.custom_fields = kwargs.pop('custom_fields', None) + self.custom_fields = kwargs.pop('custom_fields', []) super(UserReadOnlySerializer, self).__init__(*args, **kwargs) @@ -63,6 +63,7 @@ class UserReadOnlySerializer(serializers.Serializer): :return: Dict serialized account """ profile = user.profile + accomplishments_shared = settings.FEATURES.get('ENABLE_OPENBADGES') or False data = { "username": user.username, @@ -95,42 +96,19 @@ class UserReadOnlySerializer(serializers.Serializer): "level_of_education": AccountLegacyProfileSerializer.convert_empty_to_None(profile.level_of_education), "mailing_address": profile.mailing_address, "requires_parental_consent": profile.requires_parental_consent(), - "account_privacy": self._get_profile_visibility(profile, user), + "accomplishments_shared": accomplishments_shared, + "account_privacy": get_profile_visibility(profile, user, self.configuration), } - return self._filter_fields( - self._visible_fields(profile, user), - data - ) - - def _visible_fields(self, user_profile, user): - """ - Return what fields should be visible based on user settings - - :param user_profile: User profile object - :param user: User object - :return: whitelist List of fields to be shown - """ - if self.custom_fields: - return self.custom_fields - - profile_visibility = self._get_profile_visibility(user_profile, user) - - if profile_visibility == ALL_USERS_VISIBILITY: - return self.configuration.get('shareable_fields') + fields = self.custom_fields else: - return self.configuration.get('public_fields') - - def _get_profile_visibility(self, user_profile, user): - """Returns the visibility level for the specified user profile.""" - if user_profile.requires_parental_consent(): - return PRIVATE_VISIBILITY + fields = _visible_fields(profile, user, self.configuration) - # Calling UserPreference directly because the requesting user may be different from existing_user - # (and does not have to be is_staff). - profile_privacy = UserPreference.get_value(user, ACCOUNT_VISIBILITY_PREF_KEY) - return profile_privacy if profile_privacy else self.configuration.get('default_visibility') + return self._filter_fields( + fields, + data + ) def _filter_fields(self, field_whitelist, serialized_account): """ @@ -260,3 +238,37 @@ class AccountLegacyProfileSerializer(serializers.HyperlinkedModelSerializer, Rea ]) return instance + + +def get_profile_visibility(user_profile, user, configuration=None): + """Returns the visibility level for the specified user profile.""" + if user_profile.requires_parental_consent(): + return PRIVATE_VISIBILITY + + if not configuration: + configuration = settings.ACCOUNT_VISIBILITY_CONFIGURATION + + # Calling UserPreference directly because the requesting user may be different from existing_user + # (and does not have to be is_staff). + profile_privacy = UserPreference.get_value(user, ACCOUNT_VISIBILITY_PREF_KEY) + return profile_privacy if profile_privacy else configuration.get('default_visibility') + + +def _visible_fields(user_profile, user, configuration=None): + """ + Return what fields should be visible based on user settings + + :param user_profile: User profile object + :param user: User object + :param configuration: A visibility configuration dictionary. + :return: whitelist List of fields to be shown + """ + + if not configuration: + configuration = settings.ACCOUNT_VISIBILITY_CONFIGURATION + + profile_visibility = get_profile_visibility(user_profile, user, configuration) + if profile_visibility == ALL_USERS_VISIBILITY: + return configuration.get('shareable_fields') + else: + return configuration.get('public_fields') diff --git a/openedx/core/djangoapps/user_api/accounts/tests/test_api.py b/openedx/core/djangoapps/user_api/accounts/tests/test_api.py index ec2ac28..3d6e57c 100644 --- a/openedx/core/djangoapps/user_api/accounts/tests/test_api.py +++ b/openedx/core/djangoapps/user_api/accounts/tests/test_api.py @@ -87,7 +87,7 @@ class TestAccountApi(UserSettingsEventTestMixin, TestCase): account_settings = get_account_settings( self.default_request, self.different_user.username, - configuration=config + configuration=config, ) self.assertEqual(self.different_user.email, account_settings["email"]) @@ -282,6 +282,7 @@ class AccountSettingsOnCreationTest(TestCase): 'requires_parental_consent': True, 'language_proficiencies': [], 'account_privacy': PRIVATE_VISIBILITY, + 'accomplishments_shared': False, }) diff --git a/openedx/core/djangoapps/user_api/accounts/tests/test_views.py b/openedx/core/djangoapps/user_api/accounts/tests/test_views.py index db6df2f..2cd04ea 100644 --- a/openedx/core/djangoapps/user_api/accounts/tests/test_views.py +++ b/openedx/core/djangoapps/user_api/accounts/tests/test_views.py @@ -8,6 +8,7 @@ import datetime import ddt import hashlib import json + from mock import patch from nose.plugins.attrib import attr from pytz import UTC @@ -155,12 +156,12 @@ class TestAccountAPI(UserAPITestCase): } ) - def _verify_full_shareable_account_response(self, response, account_privacy=None): + def _verify_full_shareable_account_response(self, response, account_privacy=None, badges_enabled=False): """ Verify that the shareable fields from the account are returned """ data = response.data - self.assertEqual(7, len(data)) + self.assertEqual(8, len(data)) self.assertEqual(self.user.username, data["username"]) self.assertEqual("US", data["country"]) self._verify_profile_image_data(data, True) @@ -168,6 +169,7 @@ class TestAccountAPI(UserAPITestCase): self.assertEqual([{"code": "en"}], data["language_proficiencies"]) self.assertEqual("Tired mother of twins", data["bio"]) self.assertEqual(account_privacy, data["account_privacy"]) + self.assertEqual(badges_enabled, data['accomplishments_shared']) def _verify_private_account_response(self, response, requires_parental_consent=False, account_privacy=None): """ @@ -184,7 +186,7 @@ class TestAccountAPI(UserAPITestCase): Verify that all account fields are returned (even those that are not shareable). """ data = response.data - self.assertEqual(16, len(data)) + self.assertEqual(17, len(data)) self.assertEqual(self.user.username, data["username"]) self.assertEqual(self.user.first_name + " " + self.user.last_name, data["name"]) self.assertEqual("US", data["country"]) @@ -261,6 +263,7 @@ class TestAccountAPI(UserAPITestCase): response = self.send_get(self.different_client) self._verify_private_account_response(response, account_privacy=PRIVATE_VISIBILITY) + @patch.dict(settings.FEATURES, {'ENABLE_OPENBADGES': True}) @ddt.data( ("client", "user", PRIVATE_VISIBILITY), ("different_client", "different_user", PRIVATE_VISIBILITY), @@ -281,7 +284,7 @@ class TestAccountAPI(UserAPITestCase): if preference_visibility == PRIVATE_VISIBILITY: self._verify_private_account_response(response, account_privacy=PRIVATE_VISIBILITY) else: - self._verify_full_shareable_account_response(response, ALL_USERS_VISIBILITY) + self._verify_full_shareable_account_response(response, ALL_USERS_VISIBILITY, badges_enabled=True) client = self.login_client(api_client, requesting_username) @@ -311,7 +314,7 @@ class TestAccountAPI(UserAPITestCase): with self.assertNumQueries(9): response = self.send_get(self.client) data = response.data - self.assertEqual(16, len(data)) + self.assertEqual(17, len(data)) self.assertEqual(self.user.username, data["username"]) self.assertEqual(self.user.first_name + " " + self.user.last_name, data["name"]) for empty_field in ("year_of_birth", "level_of_education", "mailing_address", "bio"): @@ -326,6 +329,8 @@ class TestAccountAPI(UserAPITestCase): self.assertTrue(data["requires_parental_consent"]) self.assertEqual([], data["language_proficiencies"]) self.assertEqual(PRIVATE_VISIBILITY, data["account_privacy"]) + # Badges aren't on by default, so should not be present. + self.assertEqual(False, data["accomplishments_shared"]) self.client.login(username=self.user.username, password=self.test_password) verify_get_own_information() @@ -693,7 +698,7 @@ class TestAccountAPI(UserAPITestCase): response = self.send_get(client) if has_full_access: data = response.data - self.assertEqual(16, len(data)) + self.assertEqual(17, len(data)) self.assertEqual(self.user.username, data["username"]) self.assertEqual(self.user.first_name + " " + self.user.last_name, data["name"]) self.assertEqual(self.user.email, data["email"]) diff --git a/openedx/core/djangoapps/user_api/accounts/views.py b/openedx/core/djangoapps/user_api/accounts/views.py index 0df0643..d391fd1 100644 --- a/openedx/core/djangoapps/user_api/accounts/views.py +++ b/openedx/core/djangoapps/user_api/accounts/views.py @@ -5,8 +5,9 @@ For more information, see: https://openedx.atlassian.net/wiki/display/TNL/User+API """ from django.db import transaction -from rest_framework import status, permissions from rest_framework_jwt.authentication import JSONWebTokenAuthentication +from rest_framework import permissions +from rest_framework import status from rest_framework.response import Response from rest_framework.views import APIView @@ -14,9 +15,9 @@ from openedx.core.lib.api.authentication import ( SessionAuthenticationAllowInactiveUser, OAuth2AuthenticationAllowInactiveUser, ) -from ..errors import UserNotFound, UserNotAuthorized, AccountUpdateError, AccountValidationError from openedx.core.lib.api.parsers import MergePatchParser from .api import get_account_settings, update_account_settings +from ..errors import UserNotFound, UserNotAuthorized, AccountUpdateError, AccountValidationError class AccountView(APIView): @@ -97,6 +98,8 @@ class AccountView(APIView): * year_of_birth: The year the user was born, as an integer, or null. * account_privacy: The user's setting for sharing her personal profile. Possible values are "all_users" or "private". + * accomplishments_shared: Signals whether badges are enabled on the + platform and should be fetched. For all text fields, plain text instead of HTML is supported. The data is stored exactly as specified. Clients must HTML escape @@ -148,7 +151,8 @@ class AccountView(APIView): GET /api/user/v1/accounts/{username}/ """ try: - account_settings = get_account_settings(request, username, view=request.query_params.get('view')) + account_settings = get_account_settings( + request, username, view=request.query_params.get('view')) except UserNotFound: return Response(status=status.HTTP_403_FORBIDDEN if request.user.is_staff else status.HTTP_404_NOT_FOUND) diff --git a/openedx/core/djangoapps/user_api/permissions.py b/openedx/core/djangoapps/user_api/permissions.py new file mode 100644 index 0000000..677633d --- /dev/null +++ b/openedx/core/djangoapps/user_api/permissions.py @@ -0,0 +1,35 @@ +""" +Permissions classes for User-API aware views. +""" +from django.contrib.auth.models import User +from django.http import Http404 +from django.shortcuts import get_object_or_404 +from rest_framework import permissions + +from openedx.core.djangoapps.user_api.accounts.api import visible_fields + + +def is_field_shared_factory(field_name): + """ + Generates a permission class that grants access if a particular profile field is + shared with the requesting user. + """ + + class IsFieldShared(permissions.BasePermission): + """ + Grants access if a particular profile field is shared with the requesting user. + """ + def has_permission(self, request, view): + url_username = request.parser_context.get('kwargs', {}).get('username', '') + if request.user.username.lower() == url_username.lower(): + return True + # Staff can always see profiles. + if request.user.is_staff: + return True + # This should never return Multiple, as we don't allow case name collisions on registration. + user = get_object_or_404(User, username__iexact=url_username) + if field_name in visible_fields(user.profile, user): + return True + raise Http404() + + return IsFieldShared diff --git a/openedx/core/lib/api/paginators.py b/openedx/core/lib/api/paginators.py index ec33e78..c332fb6 100644 --- a/openedx/core/lib/api/paginators.py +++ b/openedx/core/lib/api/paginators.py @@ -15,6 +15,7 @@ class DefaultPagination(pagination.PageNumberPagination): by any subclass of Django Rest Framework's generic API views. """ page_size_query_param = "page_size" + max_page_size = 100 def get_paginated_response(self, data): """ @@ -25,6 +26,8 @@ class DefaultPagination(pagination.PageNumberPagination): 'previous': self.get_previous_link(), 'count': self.page.paginator.count, 'num_pages': self.page.paginator.num_pages, + 'current_page': self.page.number, + 'start': (self.page.number - 1) * self.get_page_size(self.request), 'results': data }) -- libgit2 0.26.0