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