Commit ab0cbfff by Kelketek

Merge pull request #10556 from edx/feature/badges-v2

Badges V2 Feature Branch
parents 343bb3e9 f84f95c7
...@@ -22,6 +22,7 @@ from urllib import urlencode ...@@ -22,6 +22,7 @@ from urllib import urlencode
import uuid import uuid
import analytics import analytics
from config_models.models import ConfigurationModel from config_models.models import ConfigurationModel
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from django.conf import settings from django.conf import settings
...@@ -45,6 +46,7 @@ from simple_history.models import HistoricalRecords ...@@ -45,6 +46,7 @@ from simple_history.models import HistoricalRecords
from track import contexts from track import contexts
from xmodule_django.models import CourseKeyField, NoneToEmptyManager from xmodule_django.models import CourseKeyField, NoneToEmptyManager
from lms.djangoapps.badges.utils import badges_enabled
from certificates.models import GeneratedCertificate from certificates.models import GeneratedCertificate
from course_modes.models import CourseMode from course_modes.models import CourseMode
from enrollment.api import _default_course_mode from enrollment.api import _default_course_mode
...@@ -1212,6 +1214,10 @@ class CourseEnrollment(models.Model): ...@@ -1212,6 +1214,10 @@ class CourseEnrollment(models.Model):
# User is allowed to enroll if they've reached this point. # User is allowed to enroll if they've reached this point.
enrollment = cls.get_or_create_enrollment(user, course_key) enrollment = cls.get_or_create_enrollment(user, course_key)
enrollment.update_enrollment(is_active=True, mode=mode) enrollment.update_enrollment(is_active=True, mode=mode)
if badges_enabled():
from lms.djangoapps.badges.events.course_meta import award_enrollment_badge
award_enrollment_badge(user)
return enrollment return enrollment
@classmethod @classmethod
......
...@@ -21,10 +21,32 @@ ...@@ -21,10 +21,32 @@
define(['backbone.paginator'], function (BackbonePaginator) { define(['backbone.paginator'], function (BackbonePaginator) {
var PagingCollection = BackbonePaginator.requestPager.extend({ var PagingCollection = BackbonePaginator.requestPager.extend({
initialize: function () { initialize: function () {
var self = this;
// These must be initialized in the constructor because otherwise all PagingCollections would point // These must be initialized in the constructor because otherwise all PagingCollections would point
// to the same object references for sortableFields and filterableFields. // to the same object references for sortableFields and filterableFields.
this.sortableFields = {}; this.sortableFields = {};
this.filterableFields = {}; 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, isZeroIndexed: false,
...@@ -41,26 +63,6 @@ ...@@ -41,26 +63,6 @@
searchString: null, 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) { parse: function (response) {
this.totalCount = response.count; this.totalCount = response.count;
this.currentPage = response.current_page; this.currentPage = response.current_page;
......
...@@ -24,18 +24,26 @@ ...@@ -24,18 +24,26 @@
this.itemViews = []; 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 () { render: function () {
// Remove old children views // Remove old children views
_.each(this.itemViews, function (childView) { _.each(this.itemViews, function (childView) {
childView.remove(); childView.remove();
}); });
this.itemViews = []; this.itemViews = [];
// Render the collection this.renderCollection();
this.collection.each(function (model) {
var itemView = new this.itemViewClass({model: model});
this.$el.append(itemView.render().el);
this.itemViews.push(itemView);
}, this);
return this; return this;
} }
}); });
......
...@@ -26,7 +26,7 @@ ...@@ -26,7 +26,7 @@
], function (Backbone, _, PagingHeader, PagingFooter, ListView, paginatedViewTemplate) { ], function (Backbone, _, PagingHeader, PagingFooter, ListView, paginatedViewTemplate) {
var PaginatedView = Backbone.View.extend({ var PaginatedView = Backbone.View.extend({
initialize: function () { initialize: function () {
var ItemListView = ListView.extend({ var ItemListView = this.listViewClass.extend({
tagName: 'div', tagName: 'div',
className: this.type + '-container', className: this.type + '-container',
itemViewClass: this.itemViewClass itemViewClass: this.itemViewClass
...@@ -39,18 +39,25 @@ ...@@ -39,18 +39,25 @@
}, this); }, this);
}, },
listViewClass: ListView,
viewTemplate: paginatedViewTemplate,
paginationLabel: gettext("Pagination"),
createHeaderView: function() { createHeaderView: function() {
return new PagingHeader({collection: this.options.collection, srInfo: this.srInfo}); return new PagingHeader({collection: this.options.collection, srInfo: this.srInfo});
}, },
createFooterView: function() { createFooterView: function() {
return new PagingFooter({ return new PagingFooter({
collection: this.options.collection, hideWhenOnePage: true collection: this.options.collection, hideWhenOnePage: true,
paginationLabel: this.paginationLabel
}); });
}, },
render: function () { 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'); this.assign(this.listView, '.' + this.type + '-list');
if (this.headerView) { if (this.headerView) {
this.assign(this.headerView, '.' + this.type + '-paging-header'); this.assign(this.headerView, '.' + this.type + '-paging-header');
...@@ -61,6 +68,12 @@ ...@@ -61,6 +68,12 @@
return this; return this;
}, },
renderError: function () {
this.$el.text(
gettext('Your request could not be completed. Reload the page and try again. If the issue persists, click the Help tab to report the problem.') // jshint ignore: line
);
},
assign: function (view, selector) { assign: function (view, selector) {
view.setElement(this.$(selector)).render(); view.setElement(this.$(selector)).render();
} }
......
...@@ -13,6 +13,7 @@ ...@@ -13,6 +13,7 @@
initialize: function(options) { initialize: function(options) {
this.collection = options.collection; this.collection = options.collection;
this.hideWhenOnePage = options.hideWhenOnePage || false; this.hideWhenOnePage = options.hideWhenOnePage || false;
this.paginationLabel = options.paginationLabel || gettext("Pagination");
this.collection.bind('add', _.bind(this.render, this)); this.collection.bind('add', _.bind(this.render, this));
this.collection.bind('remove', _.bind(this.render, this)); this.collection.bind('remove', _.bind(this.render, this));
this.collection.bind('reset', _.bind(this.render, this)); this.collection.bind('reset', _.bind(this.render, this));
...@@ -32,7 +33,8 @@ ...@@ -32,7 +33,8 @@
} }
this.$el.html(_.template(paging_footer_template)({ this.$el.html(_.template(paging_footer_template)({
current_page: this.collection.getPage(), 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.$(".previous-page-link").toggleClass("is-disabled", onFirstPage).attr('aria-disabled', onFirstPage);
this.$(".next-page-link").toggleClass("is-disabled", onLastPage).attr('aria-disabled', onLastPage); this.$(".next-page-link").toggleClass("is-disabled", onLastPage).attr('aria-disabled', onLastPage);
......
...@@ -3,9 +3,9 @@ ...@@ -3,9 +3,9 @@
define(['backbone', define(['backbone',
'underscore', 'underscore',
'jquery', 'jquery',
'text!templates/components/tabbed/tabbed_view.underscore', 'text!common/templates/components/tabbed_view.underscore',
'text!templates/components/tabbed/tab.underscore', 'text!common/templates/components/tab.underscore',
'text!templates/components/tabbed/tabpanel.underscore', 'text!common/templates/components/tabpanel.underscore',
], function ( ], function (
Backbone, Backbone,
_, _,
...@@ -37,8 +37,6 @@ ...@@ -37,8 +37,6 @@
'click .nav-item.tab': 'switchTab' 'click .nav-item.tab': 'switchTab'
}, },
template: _.template(tabbedViewTemplate),
/** /**
* View for a tabbed interface. Expects a list of tabs * View for a tabbed interface. Expects a list of tabs
* in its options object, each of which should contain the * in its options object, each of which should contain the
...@@ -51,12 +49,13 @@ ...@@ -51,12 +49,13 @@
* If a router is passed in (via options.router), * If a router is passed in (via options.router),
* use that router to keep track of history between * use that router to keep track of history between
* tabs. Backbone.history.start() must be called * tabs. Backbone.history.start() must be called
* by the router's instatiator after this view is * by the router's instantiator after this view is
* initialized. * initialized.
*/ */
initialize: function (options) { initialize: function (options) {
this.router = options.router || null; this.router = options.router || null;
this.tabs = options.tabs; this.tabs = options.tabs;
this.template = _.template(tabbedViewTemplate)({viewLabel: options.viewLabel});
// Convert each view into a TabPanelView // Convert each view into a TabPanelView
_.each(this.tabs, function (tabInfo) { _.each(this.tabs, function (tabInfo) {
tabInfo.view = new TabPanelView({url: tabInfo.url, view: tabInfo.view}); tabInfo.view = new TabPanelView({url: tabInfo.url, view: tabInfo.view});
...@@ -69,7 +68,7 @@ ...@@ -69,7 +68,7 @@
render: function () { render: function () {
var self = this; var self = this;
this.$el.html(this.template({})); this.$el.html(this.template);
_.each(this.tabs, function(tabInfo, index) { _.each(this.tabs, function(tabInfo, index) {
var tabEl = $(_.template(tabTemplate)({ var tabEl = $(_.template(tabTemplate)({
index: index, index: index,
......
...@@ -4,7 +4,7 @@ ...@@ -4,7 +4,7 @@
define(['jquery', define(['jquery',
'underscore', 'underscore',
'backbone', 'backbone',
'js/components/tabbed/views/tabbed_view' 'common/js/components/views/tabbed_view'
], ],
function($, _, Backbone, TabbedView) { function($, _, Backbone, TabbedView) {
var view, var view,
...@@ -36,7 +36,8 @@ ...@@ -36,7 +36,8 @@
title: 'Test 2', title: 'Test 2',
view: new TestSubview({text: 'other text'}), view: new TestSubview({text: 'other text'}),
url: 'test-2' url: 'test-2'
}] }],
viewLabel: 'Tabs',
}).render(); }).render();
// _.defer() is used to make calls to // _.defer() is used to make calls to
......
...@@ -155,6 +155,7 @@ ...@@ -155,6 +155,7 @@
define([ define([
// Run the common tests that use RequireJS. // 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/feedback_spec.js',
'common-requirejs/include/common/js/spec/components/list_spec.js', 'common-requirejs/include/common/js/spec/components/list_spec.js',
'common-requirejs/include/common/js/spec/components/paginated_view_spec.js', 'common-requirejs/include/common/js/spec/components/paginated_view_spec.js',
......
...@@ -72,6 +72,11 @@ define(['sinon', 'underscore', 'URI'], function(sinon, _, URI) { ...@@ -72,6 +72,11 @@ define(['sinon', 'underscore', 'URI'], function(sinon, _, URI) {
expect(request.readyState).toEqual(XML_HTTP_READY_STATES.OPENED); expect(request.readyState).toEqual(XML_HTTP_READY_STATES.OPENED);
expect(request.url).toEqual(url); expect(request.url).toEqual(url);
expect(request.method).toEqual(method); expect(request.method).toEqual(method);
if (typeof body === 'undefined') {
// The body of the request may not be germane to the current test-- like some call by a library,
// so allow it to be ignored.
return;
}
expect(request.requestBody).toEqual(body); expect(request.requestBody).toEqual(body);
}; };
......
<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 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="nav-item page">
<div class="pagination-form"> <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"/> <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> <span class="sr field-helper" id="page-number-input-helper"><%= gettext("Enter the page number you'd like to quickly navigate to.") %></span>
</div> </div>
......
<nav class="page-content-nav" aria-label="Teams"></nav> <nav class="page-content-nav" aria-label="<%- viewLabel %>"></nav>
<div class="page-content-main"> <div class="page-content-main">
<div class="tabs"></div> <div class="tabs"></div>
</div> </div>
""" """
Bok-Choy PageObject class for learner profile page. Bok-Choy PageObject class for learner profile page.
""" """
from bok_choy.query import BrowserQuery
from . import BASE_URL from . import BASE_URL
from bok_choy.page_object import PageObject from bok_choy.page_object import PageObject
from .fields import FieldsMixin from .fields import FieldsMixin
...@@ -16,6 +18,49 @@ FIELD_ICONS = { ...@@ -16,6 +18,49 @@ FIELD_ICONS = {
} }
class Badge(PageObject):
"""
Represents a single badge displayed on the learner profile page.
"""
url = None
def __init__(self, element, browser):
self.full_view = browser
# Element API is similar to browser API, should allow subqueries.
super(Badge, self).__init__(element)
def is_browser_on_page(self):
return self.q(css=".badge-details").visible
def modal_displayed(self):
"""
Verifies that the share modal is diplayed.
"""
# The modal is on the page at large, and not a subelement of the badge div.
return BrowserQuery(self.full_view, css=".badges-modal").visible
def display_modal(self):
"""
Click the share button to display the sharing modal for the badge.
"""
self.q(css=".share-button").click()
EmptyPromise(self.modal_displayed, "Share modal displayed").fulfill()
EmptyPromise(self.modal_focused, "Focus handed to modal").fulfill()
def modal_focused(self):
"""
Return True if the badges model has focus, False otherwise.
"""
return BrowserQuery(self.full_view, css=".badges-modal").is_focused()
def close_modal(self):
"""
Close the badges modal and check that it is no longer displayed.
"""
BrowserQuery(self.full_view, css=".badges-modal .close").click()
EmptyPromise(lambda: not self.modal_displayed(), "Share modal dismissed").fulfill()
class LearnerProfilePage(FieldsMixin, PageObject): class LearnerProfilePage(FieldsMixin, PageObject):
""" """
PageObject methods for Learning Profile Page. PageObject methods for Learning Profile Page.
...@@ -58,6 +103,27 @@ class LearnerProfilePage(FieldsMixin, PageObject): ...@@ -58,6 +103,27 @@ class LearnerProfilePage(FieldsMixin, PageObject):
""" """
return 'all_users' if self.q(css=PROFILE_VISIBILITY_SELECTOR.format('all_users')).selected else 'private' return 'all_users' if self.q(css=PROFILE_VISIBILITY_SELECTOR.format('all_users')).selected else 'private'
def accomplishments_available(self):
"""
Verify that the accomplishments tab is available.
"""
return self.q(css="button[data-url='accomplishments']").visible
def display_accomplishments(self):
"""
Click the accomplishments tab and wait for the accomplishments to load.
"""
EmptyPromise(self.accomplishments_available, "Accomplishments tab is displayed").fulfill()
self.q(css="button[data-url='accomplishments']").click()
self.wait_for_element_visibility(".badge-list", "Badge list displayed")
@property
def badges(self):
"""
Get all currently listed badges.
"""
return [Badge(element, self.browser) for element in self.q(css=".badge-display:not(.badge-placeholder)")]
@privacy.setter @privacy.setter
def privacy(self, privacy): def privacy(self, privacy):
""" """
......
...@@ -750,6 +750,15 @@ class DifferentUserLearnerProfilePageTest(LearnerProfileTestMixin, WebAppTest): ...@@ -750,6 +750,15 @@ class DifferentUserLearnerProfilePageTest(LearnerProfileTestMixin, WebAppTest):
self.verify_profile_page_is_public(profile_page, is_editable=False) self.verify_profile_page_is_public(profile_page, is_editable=False)
self.verify_profile_page_view_event(username, different_user_id, visibility=self.PRIVACY_PUBLIC) self.verify_profile_page_view_event(username, different_user_id, visibility=self.PRIVACY_PUBLIC)
def test_badge_share_modal(self):
username = 'testcert'
AutoAuthPage(self.browser, username=username).visit()
profile_page = self.visit_profile_page(username)
profile_page.display_accomplishments()
badge = profile_page.badges[0]
badge.display_modal()
badge.close_modal()
@attr('a11y') @attr('a11y')
class LearnerProfileA11yTest(LearnerProfileTestMixin, WebAppTest): class LearnerProfileA11yTest(LearnerProfileTestMixin, WebAppTest):
...@@ -800,3 +809,22 @@ class LearnerProfileA11yTest(LearnerProfileTestMixin, WebAppTest): ...@@ -800,3 +809,22 @@ class LearnerProfileA11yTest(LearnerProfileTestMixin, WebAppTest):
}) })
profile_page.a11y_audit.check_for_accessibility_errors() profile_page.a11y_audit.check_for_accessibility_errors()
def test_badges_accessibility(self):
"""
Test the accessibility of the badge listings and sharing modal.
"""
username = 'testcert'
AutoAuthPage(self.browser, username=username).visit()
profile_page = self.visit_profile_page(username)
profile_page.a11y_audit.config.set_rules({
"ignore": [
'skip-link', # TODO: AC-179
'link-href', # TODO: AC-231
],
})
profile_page.display_accomplishments()
profile_page.a11y_audit.check_for_accessibility_errors()
profile_page.badges[0].display_modal()
profile_page.a11y_audit.check_for_accessibility_errors()
This source diff could not be displayed because it is too large. You can view the blob instead.
...@@ -35,7 +35,7 @@ CREATE TABLE `django_migrations` ( ...@@ -35,7 +35,7 @@ CREATE TABLE `django_migrations` (
`name` varchar(255) NOT NULL, `name` varchar(255) NOT NULL,
`applied` datetime(6) NOT NULL, `applied` datetime(6) NOT NULL,
PRIMARY KEY (`id`) PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=120 DEFAULT CHARSET=utf8; ) ENGINE=InnoDB AUTO_INCREMENT=139 DEFAULT CHARSET=utf8;
/*!40101 SET character_set_client = @saved_cs_client */; /*!40101 SET character_set_client = @saved_cs_client */;
/*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */; /*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */;
......
"""
Admin registration for Badge Models
"""
from django.contrib import admin
from badges.models import CourseCompleteImageConfiguration, CourseEventBadgesConfiguration, BadgeClass
from config_models.admin import ConfigurationModelAdmin
admin.site.register(CourseCompleteImageConfiguration)
admin.site.register(BadgeClass)
# Use the standard Configuration Model Admin handler for this model.
admin.site.register(CourseEventBadgesConfiguration, ConfigurationModelAdmin)
"""
Serializers for Badges
"""
from rest_framework import serializers
from badges.models import BadgeClass, BadgeAssertion
class BadgeClassSerializer(serializers.ModelSerializer):
"""
Serializer for BadgeClass model.
"""
image_url = serializers.ImageField(source='image')
class Meta(object):
model = BadgeClass
fields = ('slug', 'issuing_component', 'display_name', 'course_id', 'description', 'criteria', 'image_url')
class BadgeAssertionSerializer(serializers.ModelSerializer):
"""
Serializer for the BadgeAssertion model.
"""
badge_class = BadgeClassSerializer(read_only=True)
class Meta(object):
model = BadgeAssertion
fields = ('badge_class', 'image_url', 'assertion_url', 'created')
"""
Tests for the badges API views.
"""
from ddt import ddt, data, unpack
from django.conf import settings
from django.test.utils import override_settings
from badges.tests.factories import BadgeAssertionFactory, BadgeClassFactory, RandomBadgeClassFactory
from openedx.core.lib.api.test_utils import ApiTestCase
from student.tests.factories import UserFactory
from util.testing import UrlResetMixin
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory
FEATURES_WITH_BADGES_ENABLED = settings.FEATURES.copy()
FEATURES_WITH_BADGES_ENABLED['ENABLE_OPENBADGES'] = True
@override_settings(FEATURES=FEATURES_WITH_BADGES_ENABLED)
class UserAssertionTestCase(UrlResetMixin, ModuleStoreTestCase, ApiTestCase):
"""
Mixin for badge API tests.
"""
def setUp(self, *args, **kwargs):
super(UserAssertionTestCase, self).setUp(*args, **kwargs)
self.course = CourseFactory.create()
self.user = UserFactory.create()
# Password defined by factory.
self.client.login(username=self.user.username, password='test')
def url(self):
"""
Return the URL to look up the current user's assertions.
"""
return '/api/badges/v1/assertions/user/{}/'.format(self.user.username)
def check_class_structure(self, badge_class, json_class):
"""
Check a JSON response against a known badge class.
"""
self.assertEqual(badge_class.issuing_component, json_class['issuing_component'])
self.assertEqual(badge_class.slug, json_class['slug'])
self.assertIn(badge_class.image.url, json_class['image_url'])
self.assertEqual(badge_class.description, json_class['description'])
self.assertEqual(badge_class.criteria, json_class['criteria'])
self.assertEqual(badge_class.course_id and unicode(badge_class.course_id), json_class['course_id'])
def check_assertion_structure(self, assertion, json_assertion):
"""
Check a JSON response against a known assertion object.
"""
self.assertEqual(assertion.image_url, json_assertion['image_url'])
self.assertEqual(assertion.assertion_url, json_assertion['assertion_url'])
self.check_class_structure(assertion.badge_class, json_assertion['badge_class'])
def get_course_id(self, wildcard, badge_class):
"""
Used for tests which may need to test for a course_id or a wildcard.
"""
if wildcard:
return '*'
else:
return unicode(badge_class.course_id)
def create_badge_class(self, check_course, **kwargs):
"""
Create a badge class, using a course id if it's relevant to the URL pattern.
"""
if check_course:
return RandomBadgeClassFactory.create(course_id=self.course.location.course_key, **kwargs)
return RandomBadgeClassFactory.create(**kwargs)
def get_qs_args(self, check_course, wildcard, badge_class):
"""
Get a dictionary to be serialized into querystring params based on class settings.
"""
qs_args = {
'issuing_component': badge_class.issuing_component,
'slug': badge_class.slug,
}
if check_course:
qs_args['course_id'] = self.get_course_id(wildcard, badge_class)
return qs_args
class TestUserBadgeAssertions(UserAssertionTestCase):
"""
Test the general badge assertions retrieval view.
"""
def test_get_assertions(self):
"""
Verify we can get all of a user's badge assertions.
"""
for dummy in range(3):
BadgeAssertionFactory(user=self.user)
# Add in a course scoped badge-- these should not be excluded from the full listing.
BadgeAssertionFactory(user=self.user, badge_class=BadgeClassFactory(course_id=self.course.location.course_key))
# Should not be included.
for dummy in range(3):
self.create_badge_class(False)
response = self.get_json(self.url())
# pylint: disable=no-member
self.assertEqual(len(response['results']), 4)
def test_assertion_structure(self):
badge_class = self.create_badge_class(False)
assertion = BadgeAssertionFactory.create(user=self.user, badge_class=badge_class)
response = self.get_json(self.url())
# pylint: disable=no-member
self.check_assertion_structure(assertion, response['results'][0])
class TestUserCourseBadgeAssertions(UserAssertionTestCase):
"""
Test the Badge Assertions view with the course_id filter.
"""
def test_get_assertions(self):
"""
Verify we can get assertions via the course_id and username.
"""
course_key = self.course.location.course_key
badge_class = BadgeClassFactory.create(course_id=course_key)
for dummy in range(3):
BadgeAssertionFactory.create(user=self.user, badge_class=badge_class)
# Should not be included, as they don't share the target badge class.
for dummy in range(3):
BadgeAssertionFactory.create(user=self.user)
# Also should not be included, as they don't share the same user.
for dummy in range(6):
BadgeAssertionFactory.create(badge_class=badge_class)
response = self.get_json(self.url(), data={'course_id': course_key})
# pylint: disable=no-member
self.assertEqual(len(response['results']), 3)
unused_course = CourseFactory.create()
response = self.get_json(self.url(), data={'course_id': unused_course.location.course_key})
# pylint: disable=no-member
self.assertEqual(len(response['results']), 0)
def test_assertion_structure(self):
"""
Verify the badge assertion structure is as expected when a course is involved.
"""
course_key = self.course.location.course_key
badge_class = BadgeClassFactory.create(course_id=course_key)
assertion = BadgeAssertionFactory.create(badge_class=badge_class, user=self.user)
response = self.get_json(self.url())
# pylint: disable=no-member
self.check_assertion_structure(assertion, response['results'][0])
@ddt
class TestUserBadgeAssertionsByClass(UserAssertionTestCase):
"""
Test the Badge Assertions view with the badge class filter.
"""
@unpack
@data((False, False), (True, False), (True, True))
def test_get_assertions(self, check_course, wildcard):
"""
Verify we can get assertions via the badge class and username.
"""
badge_class = self.create_badge_class(check_course)
for dummy in range(3):
BadgeAssertionFactory.create(user=self.user, badge_class=badge_class)
if badge_class.course_id:
# Also create a version of this badge under a different course.
alt_class = BadgeClassFactory.create(
slug=badge_class.slug, issuing_component=badge_class.issuing_component,
course_id=CourseFactory.create().location.course_key
)
BadgeAssertionFactory.create(user=self.user, badge_class=alt_class)
# Same badge class, but different user. Should not show up in the list.
for dummy in range(5):
BadgeAssertionFactory.create(badge_class=badge_class)
# Different badge class AND different user. Certainly shouldn't show up in the list!
for dummy in range(6):
BadgeAssertionFactory.create()
response = self.get_json(
self.url(),
data=self.get_qs_args(check_course, wildcard, badge_class),
)
if wildcard:
expected_length = 4
else:
expected_length = 3
# pylint: disable=no-member
self.assertEqual(len(response['results']), expected_length)
unused_class = self.create_badge_class(check_course, slug='unused_slug', issuing_component='unused_component')
response = self.get_json(
self.url(),
data=self.get_qs_args(check_course, wildcard, unused_class),
)
# pylint: disable=no-member
self.assertEqual(len(response['results']), 0)
def check_badge_class_assertion(self, check_course, wildcard, badge_class):
"""
Given a badge class, create an assertion for the current user and fetch it, checking the structure.
"""
assertion = BadgeAssertionFactory.create(badge_class=badge_class, user=self.user)
response = self.get_json(
self.url(),
data=self.get_qs_args(check_course, wildcard, badge_class),
)
# pylint: disable=no-member
self.check_assertion_structure(assertion, response['results'][0])
@unpack
@data((False, False), (True, False), (True, True))
def test_assertion_structure(self, check_course, wildcard):
self.check_badge_class_assertion(check_course, wildcard, self.create_badge_class(check_course))
@unpack
@data((False, False), (True, False), (True, True))
def test_empty_issuing_component(self, check_course, wildcard):
self.check_badge_class_assertion(
check_course, wildcard, self.create_badge_class(check_course, issuing_component='')
)
"""
URLs for badges API
"""
from django.conf import settings
from django.conf.urls import patterns, url
from .views import UserBadgeAssertions
urlpatterns = patterns(
'badges.api',
url('^assertions/user/' + settings.USERNAME_PATTERN + '/$', UserBadgeAssertions.as_view(), name='user_assertions'),
)
"""
API views for badges
"""
from opaque_keys import InvalidKeyError
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 xmodule_django.models import CourseKeyField
from badges.models import BadgeAssertion
from .serializers import BadgeAssertionSerializer
class InvalidCourseKeyError(APIException):
"""
Raised the course key given isn't valid.
"""
status_code = 400
default_detail = "The course key provided was invalid."
class UserBadgeAssertions(generics.ListAPIView):
"""
** Use cases **
Request a list of assertions for a user, optionally constrained to a course.
** Example Requests **
GET /api/badges/v1/assertions/user/{username}/
** Response Values **
Body comprised of a list of objects with the following fields:
* badge_class: The badge class the assertion was awarded for. Represented as an object
with the following fields:
* slug: The identifier for the badge class
* issuing_component: The software component responsible for issuing this badge.
* display_name: The display name of the badge.
* course_id: The course key of the course this badge is scoped to, or null if it isn't scoped to a course.
* description: A description of the award and its significance.
* criteria: A description of what is needed to obtain this award.
* image_url: A URL to the icon image used to represent this award.
* image_url: The baked assertion image derived from the badge_class icon-- contains metadata about the award
in its headers.
* assertion_url: The URL to the OpenBadges BadgeAssertion object, for verification by compatible tools
and software.
** Params **
* slug (optional): The identifier for a particular badge class to filter by.
* issuing_component (optional): The issuing component for a particular badge class to filter by
(requires slug to have been specified, or this will be ignored.) If slug is provided and this is not,
assumes the issuing_component should be empty.
* course_id (optional): Returns assertions that were awarded as part of a particular course. If slug is
provided, and this field is not specified, assumes that the target badge has an empty course_id field.
'*' may be used to get all badges with the specified slug, issuing_component combination across all courses.
** Returns **
* 200 on success, with a list of Badge Assertion objects.
* 403 if a user who does not have permission to masquerade as
another user specifies a username other than their own.
* 404 if the specified user does not exist
{
"count": 7,
"previous": null,
"num_pages": 1,
"results": [
{
"badge_class": {
"slug": "special_award",
"issuing_component": "openedx__course",
"display_name": "Very Special Award",
"course_id": "course-v1:edX+DemoX+Demo_Course",
"description": "Awarded for people who did something incredibly special",
"criteria": "Do something incredibly special.",
"image": "http://example.com/media/badge_classes/badges/special_xdpqpBv_9FYOZwN.png"
},
"image_url": "http://badges.example.com/media/issued/cd75b69fc1c979fcc1697c8403da2bdf.png",
"assertion_url": "http://badges.example.com/public/assertions/07020647-e772-44dd-98b7-d13d34335ca6"
},
...
]
}
"""
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):
"""
Get all badges for the username specified.
"""
queryset = BadgeAssertion.objects.filter(user__username=self.kwargs['username'])
provided_course_id = self.request.query_params.get('course_id')
if provided_course_id == '*':
# We might want to get all the matching course scoped badges to see how many courses
# a user managed to get a specific award on.
course_id = None
elif provided_course_id:
try:
course_id = CourseKey.from_string(provided_course_id)
except InvalidKeyError:
raise InvalidCourseKeyError
elif 'slug' not in self.request.query_params:
# Need to get all badges for the user.
course_id = None
else:
# Django won't let us use 'None' for querying a ForeignKey field. We have to use this special
# 'Empty' value to indicate we're looking only for badges without a course key set.
course_id = CourseKeyField.Empty
if course_id is not None:
queryset = queryset.filter(badge_class__course_id=course_id)
if self.request.query_params.get('slug'):
queryset = queryset.filter(
badge_class__slug=self.request.query_params['slug'],
badge_class__issuing_component=self.request.query_params.get('issuing_component', '')
)
return queryset
"""
Badge Awarding backend for Badgr-Server.
"""
import hashlib
import logging
import mimetypes
import requests
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
from lazy import lazy
from requests.packages.urllib3.exceptions import HTTPError
from badges.backends.base import BadgeBackend
from eventtracking import tracker
from badges.models import BadgeAssertion
MAX_SLUG_LENGTH = 255
LOGGER = logging.getLogger(__name__)
class BadgrBackend(BadgeBackend):
"""
Backend for Badgr-Server by Concentric Sky. http://info.badgr.io/
"""
badges = []
def __init__(self):
super(BadgrBackend, self).__init__()
if not settings.BADGR_API_TOKEN:
raise ImproperlyConfigured("BADGR_API_TOKEN not set.")
@lazy
def _base_url(self):
"""
Base URL for all API requests.
"""
return "{}/v1/issuer/issuers/{}".format(settings.BADGR_BASE_URL, settings.BADGR_ISSUER_SLUG)
@lazy
def _badge_create_url(self):
"""
URL for generating a new Badge specification
"""
return "{}/badges".format(self._base_url)
def _badge_url(self, slug):
"""
Get the URL for a course's badge in a given mode.
"""
return "{}/{}".format(self._badge_create_url, slug)
def _assertion_url(self, slug):
"""
URL for generating a new assertion.
"""
return "{}/assertions".format(self._badge_url(slug))
def _slugify(self, badge_class):
"""
Get a compatible badge slug from the specification.
"""
slug = badge_class.issuing_component + badge_class.slug
if badge_class.issuing_component and badge_class.course_id:
# Make this unique to the course, and down to 64 characters.
# We don't do this to badges without issuing_component set for backwards compatibility.
slug = hashlib.sha256(slug + unicode(badge_class.course_id)).hexdigest()
if len(slug) > MAX_SLUG_LENGTH:
# Will be 64 characters.
slug = hashlib.sha256(slug).hexdigest()
return slug
def _log_if_raised(self, response, data):
"""
Log server response if there was an error.
"""
try:
response.raise_for_status()
except HTTPError:
LOGGER.error(
u"Encountered an error when contacting the Badgr-Server. Request sent to %r with headers %r.\n"
u"and data values %r\n"
u"Response status was %s.\n%s",
response.request.url, response.request.headers,
data,
response.status_code, response.content
)
raise
def _create_badge(self, badge_class):
"""
Create the badge class on Badgr.
"""
image = badge_class.image
# We don't want to bother validating the file any further than making sure we can detect its MIME type,
# for HTTP. The Badgr-Server should tell us if there's anything in particular wrong with it.
content_type, __ = mimetypes.guess_type(image.name)
if not content_type:
raise ValueError(
u"Could not determine content-type of image! Make sure it is a properly named .png file. "
u"Filename was: {}".format(image.name)
)
files = {'image': (image.name, image, content_type)}
data = {
'name': badge_class.display_name,
'criteria': badge_class.criteria,
'slug': self._slugify(badge_class),
'description': badge_class.description,
}
result = requests.post(
self._badge_create_url, headers=self._get_headers(), data=data, files=files,
timeout=settings.BADGR_TIMEOUT
)
self._log_if_raised(result, data)
def _send_assertion_created_event(self, user, assertion):
"""
Send an analytics event to record the creation of a badge assertion.
"""
tracker.emit(
'edx.badge.assertion.created', {
'user_id': user.id,
'badge_slug': assertion.badge_class.slug,
'badge_name': assertion.badge_class.display_name,
'issuing_component': assertion.badge_class.issuing_component,
'course_id': unicode(assertion.badge_class.course_id),
'enrollment_mode': assertion.badge_class.mode,
'assertion_id': assertion.id,
'assertion_image_url': assertion.image_url,
'assertion_json_url': assertion.assertion_url,
'issuer': assertion.data.get('issuer'),
}
)
def _create_assertion(self, badge_class, user, evidence_url):
"""
Register an assertion with the Badgr server for a particular user for a specific class.
"""
data = {
'email': user.email,
'evidence': evidence_url,
}
response = requests.post(
self._assertion_url(self._slugify(badge_class)), headers=self._get_headers(), data=data,
timeout=settings.BADGR_TIMEOUT
)
self._log_if_raised(response, data)
assertion, __ = BadgeAssertion.objects.get_or_create(user=user, badge_class=badge_class)
assertion.data = response.json()
assertion.backend = 'BadgrBackend'
assertion.image_url = assertion.data['image']
assertion.assertion_url = assertion.data['json']['id']
assertion.save()
self._send_assertion_created_event(user, assertion)
return assertion
@staticmethod
def _get_headers():
"""
Headers to send along with the request-- used for authentication.
"""
return {'Authorization': 'Token {}'.format(settings.BADGR_API_TOKEN)}
def _ensure_badge_created(self, badge_class):
"""
Verify a badge has been created for this badge class, and create it if not.
"""
slug = self._slugify(badge_class)
if slug in BadgrBackend.badges:
return
response = requests.get(self._badge_url(slug), headers=self._get_headers(), timeout=settings.BADGR_TIMEOUT)
if response.status_code != 200:
self._create_badge(badge_class)
BadgrBackend.badges.append(slug)
def award(self, badge_class, user, evidence_url=None):
"""
Make sure the badge class has been created on the backend, and then award the badge class to the user.
"""
self._ensure_badge_created(badge_class)
return self._create_assertion(badge_class, user, evidence_url)
"""
Base class for badge backends.
"""
from abc import ABCMeta, abstractmethod
class BadgeBackend(object):
"""
Defines the interface for badging backends.
"""
__metaclass__ = ABCMeta
@abstractmethod
def award(self, badge_class, user, evidence_url=None):
"""
Create a badge assertion for the user using this backend.
"""
"""
Dummy backend, for use in testing.
"""
from lms.djangoapps.badges.backends.base import BadgeBackend
from lms.djangoapps.badges.tests.factories import BadgeAssertionFactory
class DummyBackend(BadgeBackend):
"""
Dummy backend that creates assertions without contacting any real-world backend.
"""
def award(self, badge_class, user, evidence_url=None):
return BadgeAssertionFactory(badge_class=badge_class, user=user)
"""
Helper functions for the course complete event that was originally included with the Badging MVP.
"""
import hashlib
import logging
from django.core.urlresolvers import reverse
from django.template.defaultfilters import slugify
from django.utils.translation import ugettext_lazy as _
from badges.models import CourseCompleteImageConfiguration, BadgeClass, BadgeAssertion
from badges.utils import site_prefix, requires_badges_enabled
from xmodule.modulestore.django import modulestore
LOGGER = logging.getLogger(__name__)
# NOTE: As these functions are carry-overs from the initial badging implementation, they are used in
# migrations. Please check the badge migrations when changing any of these functions.
def course_slug(course_key, mode):
"""
Legacy: Not to be used as a model for constructing badge slugs. Included for compatibility with the original badge
type, awarded on course completion.
Slug ought to be deterministic and limited in size so it's not too big for Badgr.
Badgr's max slug length is 255.
"""
# Seven digits should be enough to realistically avoid collisions. That's what git services use.
digest = hashlib.sha256(u"{}{}".format(unicode(course_key), unicode(mode))).hexdigest()[:7]
base_slug = slugify(unicode(course_key) + u'_{}_'.format(mode))[:248]
return base_slug + digest
def badge_description(course, mode):
"""
Returns a description for the earned badge.
"""
if course.end:
return _(u'Completed the course "{course_name}" ({course_mode}, {start_date} - {end_date})').format(
start_date=course.start.date(),
end_date=course.end.date(),
course_name=course.display_name,
course_mode=mode,
)
else:
return _(u'Completed the course "{course_name}" ({course_mode})').format(
course_name=course.display_name,
course_mode=mode,
)
def evidence_url(user_id, course_key):
"""
Generates a URL to the user's Certificate HTML view, along with a GET variable that will signal the evidence visit
event.
"""
return site_prefix() + reverse(
'certificates:html_view', kwargs={'user_id': user_id, 'course_id': unicode(course_key)}) + '?evidence_visit=1'
def criteria(course_key):
"""
Constructs the 'criteria' URL from the course about page.
"""
about_path = reverse('about_course', kwargs={'course_id': unicode(course_key)})
return u'{}{}'.format(site_prefix(), about_path)
def get_completion_badge(course_id, user):
"""
Given a course key and a user, find the user's enrollment mode
and get the Course Completion badge.
"""
from student.models import CourseEnrollment
badge_classes = CourseEnrollment.objects.filter(
user=user, course_id=course_id
).order_by('-is_active')
if not badge_classes:
return None
mode = badge_classes[0].mode
course = modulestore().get_course(course_id)
if not course.issue_badges:
return None
return BadgeClass.get_badge_class(
slug=course_slug(course_id, mode),
issuing_component='',
criteria=criteria(course_id),
description=badge_description(course, mode),
course_id=course_id,
mode=mode,
display_name=course.display_name,
image_file_handle=CourseCompleteImageConfiguration.image_for_mode(mode)
)
@requires_badges_enabled
def course_badge_check(user, course_key):
"""
Takes a GeneratedCertificate instance, and checks to see if a badge exists for this course, creating
it if not, should conditions be right.
"""
if not modulestore().get_course(course_key).issue_badges:
LOGGER.info("Course is not configured to issue badges.")
return
badge_class = get_completion_badge(course_key, user)
if not badge_class:
# We're not configured to make a badge for this course mode.
return
if BadgeAssertion.objects.filter(user=user, badge_class=badge_class):
LOGGER.info("Completion badge already exists for this user on this course.")
# Badge already exists. Skip.
return
evidence = evidence_url(user.id, course_key)
badge_class.award(user, evidence_url=evidence)
"""
Events which have to do with a user doing something with more than one course, such
as enrolling in a certain number, completing a certain number, or completing a specific set of courses.
"""
from badges.models import CourseEventBadgesConfiguration, BadgeClass
from badges.utils import requires_badges_enabled
def award_badge(config, count, user):
"""
Given one of the configurations for enrollments or completions, award
the appropriate badge if one is configured.
config is a dictionary with integer keys and course keys as values.
count is the key to retrieve from this dictionary.
user is the user to award the badge to.
Example config:
{3: 'slug_for_badge_for_three_enrollments', 5: 'slug_for_badge_with_five_enrollments'}
"""
slug = config.get(count)
if not slug:
return
badge_class = BadgeClass.get_badge_class(
slug=slug, issuing_component='openedx__course', create=False,
)
if not badge_class:
return
if not badge_class.get_for_user(user):
badge_class.award(user)
def award_enrollment_badge(user):
"""
Awards badges based on the number of courses a user is enrolled in.
"""
config = CourseEventBadgesConfiguration.current().enrolled_settings
enrollments = user.courseenrollment_set.filter(is_active=True).count()
award_badge(config, enrollments, user)
@requires_badges_enabled
def completion_check(user):
"""
Awards badges based upon the number of courses a user has 'completed'.
Courses are never truly complete, but they can be closed.
For this reason we use checks on certificates to find out if a user has
completed courses. This badge will not work if certificate generation isn't
enabled and run.
"""
from certificates.models import CertificateStatuses
config = CourseEventBadgesConfiguration.current().completed_settings
certificates = user.generatedcertificate_set.filter(status__in=CertificateStatuses.PASSED_STATUSES).count()
award_badge(config, certificates, user)
@requires_badges_enabled
def course_group_check(user, course_key):
"""
Awards a badge if a user has completed every course in a defined set.
"""
from certificates.models import CertificateStatuses
config = CourseEventBadgesConfiguration.current().course_group_settings
awards = []
for slug, keys in config.items():
if course_key in keys:
certs = user.generatedcertificate_set.filter(
status__in=CertificateStatuses.PASSED_STATUSES,
course_id__in=keys,
)
if len(certs) == len(keys):
awards.append(slug)
for slug in awards:
badge_class = BadgeClass.get_badge_class(
slug=slug, issuing_component='openedx__course', create=False,
)
if badge_class and not badge_class.get_for_user(user):
badge_class.award(user)
"""
Tests for the course completion helper functions.
"""
from datetime import datetime
from student.tests.factories import UserFactory
from xmodule.modulestore.tests.factories import CourseFactory
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from badges.events import course_complete
class CourseCompleteTestCase(ModuleStoreTestCase):
"""
Tests for the course completion helper functions.
"""
def setUp(self, **kwargs):
super(CourseCompleteTestCase, self).setUp()
# Need key to be deterministic to test slugs.
self.course = CourseFactory.create(
org='edX', course='course_test', run='test_run', display_name='Badged',
start=datetime(year=2015, month=5, day=19),
end=datetime(year=2015, month=5, day=20)
)
self.course_key = self.course.location.course_key
def test_slug(self):
"""
Verify slug generation is working as expected. If this test fails, the algorithm has changed, and it will cause
the handler to lose track of all badges it made in the past.
"""
self.assertEqual(
course_complete.course_slug(self.course_key, 'honor'),
'edxcourse_testtest_run_honor_fc5519b'
)
self.assertEqual(
course_complete.course_slug(self.course_key, 'verified'),
'edxcourse_testtest_run_verified_a199ec0'
)
def test_dated_description(self):
"""
Verify that a course with start/end dates contains a description with them.
"""
self.assertEqual(
course_complete.badge_description(self.course, 'honor'),
'Completed the course "Badged" (honor, 2015-05-19 - 2015-05-20)'
)
def test_self_paced_description(self):
"""
Verify that a badge created for a course with no end date gets a different description.
"""
self.course.end = None
self.assertEqual(
course_complete.badge_description(self.course, 'honor'),
'Completed the course "Badged" (honor)'
)
def test_evidence_url(self):
"""
Make sure the evidence URL points to the right place.
"""
user = UserFactory.create()
self.assertEqual(
'https://edx.org/certificates/user/{user_id}/course/{course_key}?evidence_visit=1'.format(
user_id=user.id, course_key=self.course_key
),
course_complete.evidence_url(user.id, self.course_key)
)
"""
Tests the course meta badging events
"""
from ddt import ddt, unpack, data
from django.test.utils import override_settings
from mock import patch
from django.conf import settings
from badges.tests.factories import RandomBadgeClassFactory, CourseEventBadgesConfigurationFactory
from certificates.models import GeneratedCertificate, CertificateStatuses
from student.models import CourseEnrollment
from student.tests.factories import UserFactory
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory
@ddt
@patch.dict(settings.FEATURES, {'ENABLE_OPENBADGES': True})
@override_settings(BADGING_BACKEND='lms.djangoapps.badges.backends.tests.dummy_backend.DummyBackend')
class CourseEnrollmentBadgeTest(ModuleStoreTestCase):
"""
Tests the event which awards badges based on number of courses a user is enrolled in.
"""
def setUp(self):
super(CourseEnrollmentBadgeTest, self).setUp()
self.badge_classes = [
RandomBadgeClassFactory(
issuing_component='openedx__course'
),
RandomBadgeClassFactory(
issuing_component='openedx__course'
),
RandomBadgeClassFactory(
issuing_component='openedx__course'
),
]
nums = ['3', '5', '8']
entries = [','.join(pair) for pair in zip(nums, [badge.slug for badge in self.badge_classes])]
enrollment_config = '\r'.join(entries)
self.config = CourseEventBadgesConfigurationFactory(courses_enrolled=enrollment_config)
def test_no_match(self):
"""
Make sure a badge isn't created before a user's reached any checkpoint.
"""
user = UserFactory()
course = CourseFactory()
# pylint: disable=no-member
CourseEnrollment.enroll(user, course_key=course.location.course_key)
self.assertFalse(user.badgeassertion_set.all())
@unpack
@data((1, 3), (2, 5), (3, 8))
def test_checkpoint_matches(self, checkpoint, required_badges):
"""
Make sure the proper badges are awarded at the right checkpoints.
"""
user = UserFactory()
courses = [CourseFactory() for _i in range(required_badges)]
for course in courses:
CourseEnrollment.enroll(user, course_key=course.location.course_key)
# pylint: disable=no-member
assertions = user.badgeassertion_set.all().order_by('id')
self.assertEqual(user.badgeassertion_set.all().count(), checkpoint)
self.assertEqual(assertions[checkpoint - 1].badge_class, self.badge_classes[checkpoint - 1])
@ddt
@patch.dict(settings.FEATURES, {'ENABLE_OPENBADGES': True})
@override_settings(BADGING_BACKEND='lms.djangoapps.badges.backends.tests.dummy_backend.DummyBackend')
class CourseCompletionBadgeTest(ModuleStoreTestCase):
"""
Tests the event which awards badges based on the number of courses completed.
"""
def setUp(self, **kwargs):
super(CourseCompletionBadgeTest, self).setUp()
self.badge_classes = [
RandomBadgeClassFactory(
issuing_component='openedx__course'
),
RandomBadgeClassFactory(
issuing_component='openedx__course'
),
RandomBadgeClassFactory(
issuing_component='openedx__course'
),
]
nums = ['2', '6', '9']
entries = [','.join(pair) for pair in zip(nums, [badge.slug for badge in self.badge_classes])]
completed_config = '\r'.join(entries)
self.config = CourseEventBadgesConfigurationFactory.create(courses_completed=completed_config)
self.config.clean_fields()
def test_no_match(self):
"""
Make sure a badge isn't created before a user's reached any checkpoint.
"""
user = UserFactory()
course = CourseFactory()
GeneratedCertificate(
# pylint: disable=no-member
user=user, course_id=course.location.course_key, status=CertificateStatuses.downloadable
).save()
# pylint: disable=no-member
self.assertFalse(user.badgeassertion_set.all())
@unpack
@data((1, 2), (2, 6), (3, 9))
def test_checkpoint_matches(self, checkpoint, required_badges):
"""
Make sure the proper badges are awarded at the right checkpoints.
"""
user = UserFactory()
courses = [CourseFactory() for _i in range(required_badges)]
for course in courses:
GeneratedCertificate(
# pylint: disable=no-member
user=user, course_id=course.location.course_key, status=CertificateStatuses.downloadable
).save()
# pylint: disable=no-member
assertions = user.badgeassertion_set.all().order_by('id')
# pylint: disable=no-member
self.assertEqual(user.badgeassertion_set.all().count(), checkpoint)
self.assertEqual(assertions[checkpoint - 1].badge_class, self.badge_classes[checkpoint - 1])
@patch.dict(settings.FEATURES, {'ENABLE_OPENBADGES': True})
@override_settings(BADGING_BACKEND='lms.djangoapps.badges.backends.tests.dummy_backend.DummyBackend')
class CourseGroupBadgeTest(ModuleStoreTestCase):
"""
Tests the event which awards badges when a user completes a set of courses.
"""
def setUp(self):
super(CourseGroupBadgeTest, self).setUp()
self.badge_classes = [
RandomBadgeClassFactory(
issuing_component='openedx__course'
),
RandomBadgeClassFactory(
issuing_component='openedx__course'
),
RandomBadgeClassFactory(
issuing_component='openedx__course'
),
]
self.courses = []
for _badge_class in self.badge_classes:
# pylint: disable=no-member
self.courses.append([CourseFactory().location.course_key for _i in range(3)])
lines = [badge_class.slug + ',' + ','.join([unicode(course_key) for course_key in keys])
for badge_class, keys in zip(self.badge_classes, self.courses)]
config = '\r'.join(lines)
self.config = CourseEventBadgesConfigurationFactory(course_groups=config)
self.config_map = dict(zip(self.badge_classes, self.courses))
def test_no_match(self):
"""
Make sure a badge isn't created before a user's completed any course groups.
"""
user = UserFactory()
course = CourseFactory()
GeneratedCertificate(
# pylint: disable=no-member
user=user, course_id=course.location.course_key, status=CertificateStatuses.downloadable
).save()
# pylint: disable=no-member
self.assertFalse(user.badgeassertion_set.all())
def test_group_matches(self):
"""
Make sure the proper badges are awarded when groups are completed.
"""
user = UserFactory()
items = list(self.config_map.items())
for badge_class, course_keys in items:
for i, key in enumerate(course_keys):
GeneratedCertificate(
user=user, course_id=key, status=CertificateStatuses.downloadable
).save()
# We don't award badges until all three are set.
if i + 1 == len(course_keys):
self.assertTrue(badge_class.get_for_user(user))
else:
self.assertFalse(badge_class.get_for_user(user))
# pylint: disable=no-member
classes = [badge.badge_class.id for badge in user.badgeassertion_set.all()]
source_classes = [badge.id for badge in self.badge_classes]
self.assertEqual(classes, source_classes)
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
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
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='BadgeAssertion',
fields=[
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
('data', jsonfield.fields.JSONField()),
('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, 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()),
('criteria', models.TextField()),
('mode', models.CharField(default=b'', max_length=100, blank=True)),
('image', models.ImageField(upload_to=b'badge_classes', validators=[badges.models.validate_badge_image])),
],
),
migrations.CreateModel(
name='CourseCompleteImageConfiguration',
fields=[
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
('mode', models.CharField(help_text='The course mode for this badge image. For example, "verified" or "honor".', unique=True, max_length=125)),
('icon', models.ImageField(help_text='Badge images must be square PNG files. The file size should be under 250KB.', upload_to=b'course_complete_badges', validators=[badges.models.validate_badge_image])),
('default', models.BooleanField(default=False, help_text='Set this value to True if you want this image to be the default image for any course modes that do not have a specified badge image. You can have only one default image.')),
],
),
migrations.AlterUniqueTogether(
name='badgeclass',
unique_together=set([('slug', 'issuing_component', 'course_id')]),
),
migrations.AddField(
model_name='badgeassertion',
name='badge_class',
field=models.ForeignKey(to='badges.BadgeClass'),
),
migrations.AddField(
model_name='badgeassertion',
name='user',
field=models.ForeignKey(to=settings.AUTH_USER_MODEL),
),
]
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
import json
from datetime import datetime
import os
import time
from django.db import migrations, models
def forwards(apps, schema_editor):
"""
Migrate the initial badge classes, assertions, and course image configurations from certificates.
"""
from django.core.files.base import ContentFile
from xmodule.modulestore.django import modulestore
from badges.events import course_complete
db_alias = schema_editor.connection.alias
# This will need to be changed if badges/certificates get moved out of the default db for some reason.
if db_alias != 'default':
return
classes = {}
OldBadgeAssertion = apps.get_model("certificates", "BadgeAssertion")
BadgeImageConfiguration = apps.get_model("certificates", "BadgeImageConfiguration")
BadgeAssertion = apps.get_model("badges", "BadgeAssertion")
BadgeClass = apps.get_model("badges", "BadgeClass")
CourseCompleteImageConfiguration = apps.get_model("badges", "CourseCompleteImageConfiguration")
for badge in OldBadgeAssertion.objects.all():
if (badge.course_id, badge.mode) not in classes:
course = modulestore().get_course(badge.course_id)
image_config = BadgeImageConfiguration.objects.get(mode=badge.mode)
icon = image_config.icon
badge_class = BadgeClass(
display_name=course.display_name,
criteria=course_complete.evidence_url(badge.user_id, badge.course_id),
description=course_complete.badge_description(course, badge.mode),
slug=course_complete.course_slug(badge.course_id, badge.mode),
mode=image_config.mode,
course_id=badge.course_id,
)
badge_class._meta.get_field('image').generate_filename = \
lambda inst, fn: os.path.join('badge_classes', fn)
badge_class.image.name = icon.name
badge_class.save()
classes[(badge.course_id, badge.mode)] = badge_class
if isinstance(badge.data, basestring):
data = badge.data
else:
data = json.dumps(badge.data)
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'],
)
assertion.save()
# Would be overwritten by the first save.
assertion.created = datetime.fromtimestamp(
# Later versions of badgr include microseconds, but they aren't certain to be there.
time.mktime(time.strptime(badge.data['created_at'].split('.')[0], "%Y-%m-%dT%H:%M:%S"))
)
assertion.save()
for configuration in BadgeImageConfiguration.objects.all():
new_conf = CourseCompleteImageConfiguration(
default=configuration.default,
mode=configuration.mode,
)
new_conf.icon.name = configuration.icon.name
new_conf.save()
#
def backwards(apps, schema_editor):
OldBadgeAssertion = apps.get_model("certificates", "BadgeAssertion")
BadgeAssertion = apps.get_model("badges", "BadgeAssertion")
BadgeImageConfiguration = apps.get_model("certificates", "BadgeImageConfiguration")
CourseCompleteImageConfiguration = apps.get_model("badges", "CourseCompleteImageConfiguration")
for badge in BadgeAssertion.objects.all():
if not badge.badge_class.mode:
# Can't preserve old badges without modes.
continue
if isinstance(badge.data, basestring):
data = badge.data
else:
data = json.dumps(badge.data)
OldBadgeAssertion(
user_id=badge.user_id,
course_id=badge.badge_class.course_id,
mode=badge.badge_class.mode,
data=data,
).save()
for configuration in CourseCompleteImageConfiguration.objects.all():
new_conf = BadgeImageConfiguration(
default=configuration.default,
mode=configuration.mode,
)
new_conf.icon.name = configuration.icon.name
new_conf.save()
class Migration(migrations.Migration):
dependencies = [
('badges', '0001_initial'),
('certificates', '0007_certificateinvalidation')
]
operations = [
migrations.RunPython(forwards, backwards)
]
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
import django.db.models.deletion
from django.conf import settings
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('badges', '0002_data__migrate_assertions'),
]
operations = [
migrations.CreateModel(
name='CourseEventBadgesConfiguration',
fields=[
('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 that has the issuing component 'openedx__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 that has the issuing component 'openedx__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 you have created that has an issuing component of 'openedx__course'. The remaining items in each line are the course keys the learner needs 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')),
],
),
migrations.AlterModelOptions(
name='badgeclass',
options={'verbose_name_plural': 'Badge Classes'},
),
]
"""
Badging service for XBlocks
"""
from badges.models import BadgeClass
class BadgingService(object):
"""
A class that provides functions for managing badges which XBlocks can use.
If course_enabled is True, course-level badges are permitted for this course.
If it is False, any badges that are awarded should be non-course specific.
"""
course_badges_enabled = False
def __init__(self, course_id=None, modulestore=None):
"""
Sets the 'course_badges_enabled' parameter.
"""
if not (course_id and modulestore):
return
course = modulestore.get_course(course_id)
if course:
self.course_badges_enabled = course.issue_badges
get_badge_class = BadgeClass.get_badge_class
"""
Factories for Badge tests
"""
from random import random
import factory
from django.core.files.base import ContentFile
from factory import DjangoModelFactory
from factory.django import ImageField
from badges.models import BadgeAssertion, CourseCompleteImageConfiguration, BadgeClass, CourseEventBadgesConfiguration
from student.tests.factories import UserFactory
def generate_dummy_image(_unused):
"""
Used for image fields to create a sane default.
"""
return ContentFile(
ImageField()._make_data( # pylint: disable=protected-access
{'color': 'blue', 'width': 50, 'height': 50, 'format': 'PNG'}
), 'test.png'
)
class CourseCompleteImageConfigurationFactory(DjangoModelFactory):
"""
Factory for BadgeImageConfigurations
"""
class Meta(object):
model = CourseCompleteImageConfiguration
mode = 'honor'
icon = factory.LazyAttribute(generate_dummy_image)
class BadgeClassFactory(DjangoModelFactory):
"""
Factory for BadgeClass
"""
class Meta(object):
model = BadgeClass
slug = 'test_slug'
issuing_component = 'test_component'
display_name = 'Test Badge'
description = "Yay! It's a test badge."
criteria = 'https://example.com/syllabus'
mode = 'honor'
image = factory.LazyAttribute(generate_dummy_image)
class RandomBadgeClassFactory(BadgeClassFactory):
"""
Same as BadgeClassFactory, but randomize the slug.
"""
slug = factory.lazy_attribute(lambda _: 'test_slug_' + str(random()).replace('.', '_'))
class BadgeAssertionFactory(DjangoModelFactory):
"""
Factory for BadgeAssertions
"""
class Meta(object):
model = BadgeAssertion
user = factory.SubFactory(UserFactory)
badge_class = factory.SubFactory(RandomBadgeClassFactory)
data = {}
assertion_url = 'http://example.com/example.json'
image_url = 'http://example.com/image.png'
class CourseEventBadgesConfigurationFactory(DjangoModelFactory):
"""
Factory for CourseEventsBadgesConfiguration
"""
class Meta(object):
model = CourseEventBadgesConfiguration
enabled = True
"""
Utility functions used by the badging app.
"""
from django.conf import settings
def site_prefix():
"""
Get the prefix for the site URL-- protocol and server name.
"""
scheme = u"https" if settings.HTTPS == "on" else u"http"
return u'{}://{}'.format(scheme, settings.SITE_NAME)
def requires_badges_enabled(function):
"""
Decorator that bails a function out early if badges aren't enabled.
"""
def wrapped(*args, **kwargs):
"""
Wrapped function which bails out early if bagdes aren't enabled.
"""
if not badges_enabled():
return
return function(*args, **kwargs)
return wrapped
def badges_enabled():
"""
returns a boolean indicating whether or not openbadges are enabled.
"""
return settings.FEATURES.get('ENABLE_OPENBADGES', False)
def deserialize_count_specs(text):
"""
Takes a string in the format of:
int,course_key
int,course_key
And returns a dictionary with the keys as the numbers and the values as the course keys.
"""
specs = text.splitlines()
specs = [line.split(',') for line in specs if line.strip()]
return {int(num): slug.strip().lower() for num, slug in specs}
...@@ -8,7 +8,6 @@ from util.organizations_helpers import get_organizations ...@@ -8,7 +8,6 @@ from util.organizations_helpers import get_organizations
from certificates.models import ( from certificates.models import (
CertificateGenerationConfiguration, CertificateGenerationConfiguration,
CertificateHtmlViewConfiguration, CertificateHtmlViewConfiguration,
BadgeImageConfiguration,
CertificateTemplate, CertificateTemplate,
CertificateTemplateAsset, CertificateTemplateAsset,
GeneratedCertificate, GeneratedCertificate,
...@@ -61,7 +60,6 @@ class GeneratedCertificateAdmin(admin.ModelAdmin): ...@@ -61,7 +60,6 @@ class GeneratedCertificateAdmin(admin.ModelAdmin):
admin.site.register(CertificateGenerationConfiguration) admin.site.register(CertificateGenerationConfiguration)
admin.site.register(CertificateHtmlViewConfiguration, ConfigurationModelAdmin) admin.site.register(CertificateHtmlViewConfiguration, ConfigurationModelAdmin)
admin.site.register(BadgeImageConfiguration)
admin.site.register(CertificateTemplate, CertificateTemplateAdmin) admin.site.register(CertificateTemplate, CertificateTemplateAdmin)
admin.site.register(CertificateTemplateAsset, CertificateTemplateAssetAdmin) admin.site.register(CertificateTemplateAsset, CertificateTemplateAssetAdmin)
admin.site.register(GeneratedCertificate, GeneratedCertificateAdmin) admin.site.register(GeneratedCertificate, GeneratedCertificateAdmin)
"""
BadgeHandler object-- used to award Badges to users who have completed courses.
"""
import hashlib
import logging
import mimetypes
from eventtracking import tracker
import requests
from django.template.defaultfilters import slugify
from django.utils.translation import ugettext as _
from django.conf import settings
from django.core.urlresolvers import reverse
from lazy import lazy
from requests.packages.urllib3.exceptions import HTTPError
from certificates.models import BadgeAssertion, BadgeImageConfiguration
from student.models import CourseEnrollment
from xmodule.modulestore.django import modulestore
LOGGER = logging.getLogger(__name__)
class BadgeHandler(object):
"""
The only properly public method of this class is 'award'. If an alternative object is created for a different
badging service, the other methods don't need to be reproduced.
"""
# Global caching dict
badges = {}
def __init__(self, course_key):
self.course_key = course_key
assert settings.BADGR_API_TOKEN
@lazy
def base_url(self):
"""
Base URL for all API requests.
"""
return "{}/v1/issuer/issuers/{}".format(settings.BADGR_BASE_URL, settings.BADGR_ISSUER_SLUG)
@lazy
def badge_create_url(self):
"""
URL for generating a new Badge specification
"""
return "{}/badges".format(self.base_url)
def badge_url(self, mode):
"""
Get the URL for a course's badge in a given mode.
"""
return "{}/{}".format(self.badge_create_url, self.course_slug(mode))
def assertion_url(self, mode):
"""
URL for generating a new assertion.
"""
return "{}/assertions".format(self.badge_url(mode))
def course_slug(self, mode):
"""
Slug ought to be deterministic and limited in size so it's not too big for Badgr.
Badgr's max slug length is 255.
"""
# Seven digits should be enough to realistically avoid collisions. That's what git services use.
digest = hashlib.sha256(u"{}{}".format(unicode(self.course_key), unicode(mode))).hexdigest()[:7]
base_slug = slugify(unicode(self.course_key) + u'_{}_'.format(mode))[:248]
return base_slug + digest
def log_if_raised(self, response, data):
"""
Log server response if there was an error.
"""
try:
response.raise_for_status()
except HTTPError:
LOGGER.error(
u"Encountered an error when contacting the Badgr-Server. Request sent to %s with headers %s.\n"
u"and data values %s\n"
u"Response status was %s.\n%s",
repr(response.request.url), repr(response.request.headers),
repr(data),
response.status_code, response.body
)
raise
def get_headers(self):
"""
Headers to send along with the request-- used for authentication.
"""
return {'Authorization': 'Token {}'.format(settings.BADGR_API_TOKEN)}
def ensure_badge_created(self, mode):
"""
Verify a badge has been created for this mode of the course, and, if not, create it
"""
if self.course_slug(mode) in BadgeHandler.badges:
return
response = requests.get(self.badge_url(mode), headers=self.get_headers())
if response.status_code != 200:
self.create_badge(mode)
BadgeHandler.badges[self.course_slug(mode)] = True
@staticmethod
def badge_description(course, mode):
"""
Returns a description for the earned badge.
"""
if course.end:
return _(u'Completed the course "{course_name}" ({course_mode}, {start_date} - {end_date})').format(
start_date=course.start.date(),
end_date=course.end.date(),
course_name=course.display_name,
course_mode=mode,
)
else:
return _(u'Completed the course "{course_name}" ({course_mode})').format(
course_name=course.display_name,
course_mode=mode,
)
def site_prefix(self):
"""
Get the prefix for the site URL-- protocol and server name.
"""
scheme = u"https" if settings.HTTPS == "on" else u"http"
return u'{}://{}'.format(scheme, settings.SITE_NAME)
def create_badge(self, mode):
"""
Create the badge spec for a course's mode.
"""
course = modulestore().get_course(self.course_key)
image = BadgeImageConfiguration.image_for_mode(mode)
# We don't want to bother validating the file any further than making sure we can detect its MIME type,
# for HTTP. The Badgr-Server should tell us if there's anything in particular wrong with it.
content_type, __ = mimetypes.guess_type(image.name)
if not content_type:
raise ValueError(
"Could not determine content-type of image! Make sure it is a properly named .png file."
)
files = {'image': (image.name, image, content_type)}
about_path = reverse('about_course', kwargs={'course_id': unicode(self.course_key)})
data = {
'name': course.display_name,
'criteria': u'{}{}'.format(self.site_prefix(), about_path),
'slug': self.course_slug(mode),
'description': self.badge_description(course, mode)
}
result = requests.post(self.badge_create_url, headers=self.get_headers(), data=data, files=files)
self.log_if_raised(result, data)
def send_assertion_created_event(self, user, assertion):
"""
Send an analytics event to record the creation of a badge assertion.
"""
tracker.emit(
'edx.badge.assertion.created', {
'user_id': user.id,
'course_id': unicode(self.course_key),
'enrollment_mode': assertion.mode,
'assertion_id': assertion.id,
'assertion_image_url': assertion.data['image'],
'assertion_json_url': assertion.data['json']['id'],
'issuer': assertion.data['issuer'],
}
)
def create_assertion(self, user, mode):
"""
Register an assertion with the Badgr server for a particular user in a particular course mode for
this course.
"""
data = {
'email': user.email,
'evidence': self.site_prefix() + reverse(
'certificates:html_view', kwargs={'user_id': user.id, 'course_id': unicode(self.course_key)}
) + '?evidence_visit=1'
}
response = requests.post(self.assertion_url(mode), headers=self.get_headers(), data=data)
self.log_if_raised(response, data)
assertion, __ = BadgeAssertion.objects.get_or_create(course_id=self.course_key, user=user, mode=mode)
assertion.data = response.json()
assertion.save()
self.send_assertion_created_event(user, assertion)
def award(self, user):
"""
Award a user a badge for their work on the course.
"""
mode = CourseEnrollment.objects.get(user=user, course_id=self.course_key).mode
self.ensure_badge_created(mode)
self.create_assertion(user, mode)
...@@ -8,8 +8,9 @@ from django.core.management.base import BaseCommand, CommandError ...@@ -8,8 +8,9 @@ from django.core.management.base import BaseCommand, CommandError
from opaque_keys import InvalidKeyError from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import CourseKey from opaque_keys.edx.keys import CourseKey
from opaque_keys.edx.locations import SlashSeparatedCourseKey from opaque_keys.edx.locations import SlashSeparatedCourseKey
from badges.events.course_complete import get_completion_badge
from xmodule.modulestore.django import modulestore from xmodule.modulestore.django import modulestore
from certificates.models import BadgeAssertion
from certificates.api import regenerate_user_certificates from certificates.api import regenerate_user_certificates
LOGGER = logging.getLogger(__name__) LOGGER = logging.getLogger(__name__)
...@@ -110,6 +111,14 @@ class Command(BaseCommand): ...@@ -110,6 +111,14 @@ class Command(BaseCommand):
course_id course_id
) )
if course.issue_badges:
badge_class = get_completion_badge(course_id, student)
badge = badge_class.get_for_user(student)
if badge:
badge.delete()
LOGGER.info(u"Cleared badge for student %s.", student.id)
# Add the certificate request to the queue # Add the certificate request to the queue
ret = regenerate_user_certificates( ret = regenerate_user_certificates(
student, course_id, course=course, student, course_id, course=course,
...@@ -118,13 +127,6 @@ class Command(BaseCommand): ...@@ -118,13 +127,6 @@ class Command(BaseCommand):
insecure=options['insecure'] insecure=options['insecure']
) )
try:
badge = BadgeAssertion.objects.get(user=student, course_id=course_id)
badge.delete()
LOGGER.info(u"Cleared badge for student %s.", student.id)
except BadgeAssertion.DoesNotExist:
pass
LOGGER.info( LOGGER.info(
( (
u"Added a certificate regeneration task to the XQueue " u"Added a certificate regeneration task to the XQueue "
......
...@@ -9,6 +9,7 @@ import django_extensions.db.fields ...@@ -9,6 +9,7 @@ import django_extensions.db.fields
import django_extensions.db.fields.json import django_extensions.db.fields.json
import django.db.models.deletion import django.db.models.deletion
import django.utils.timezone import django.utils.timezone
from badges.models import validate_badge_image
from django.conf import settings from django.conf import settings
...@@ -34,7 +35,7 @@ class Migration(migrations.Migration): ...@@ -34,7 +35,7 @@ class Migration(migrations.Migration):
fields=[ fields=[
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
('mode', models.CharField(help_text='The course mode for this badge image. For example, "verified" or "honor".', unique=True, max_length=125)), ('mode', models.CharField(help_text='The course mode for this badge image. For example, "verified" or "honor".', unique=True, max_length=125)),
('icon', models.ImageField(help_text='Badge images must be square PNG files. The file size should be under 250KB.', upload_to=b'badges', validators=[certificates.models.validate_badge_image])), ('icon', models.ImageField(help_text='Badge images must be square PNG files. The file size should be under 250KB.', upload_to=b'badges', validators=[validate_badge_image])),
('default', models.BooleanField(default=False, help_text='Set this value to True if you want this image to be the default image for any course modes that do not have a specified badge image. You can have only one default image.')), ('default', models.BooleanField(default=False, help_text='Set this value to True if you want this image to be the default image for any course modes that do not have a specified badge image. You can have only one default image.')),
], ],
), ),
......
...@@ -10,8 +10,11 @@ from django.core.files import File ...@@ -10,8 +10,11 @@ from django.core.files import File
def forwards(apps, schema_editor): def forwards(apps, schema_editor):
"""Add default modes""" """Add default modes"""
BadgeImageConfiguration = apps.get_model("certificates", "BadgeImageConfiguration") BadgeImageConfiguration = apps.get_model("certificates", "BadgeImageConfiguration")
db_alias = schema_editor.connection.alias
objects = BadgeImageConfiguration.objects # This will need to be changed if badges/certificates get moved out of the default db for some reason.
if db_alias != 'default':
return
objects = BadgeImageConfiguration.objects.using(db_alias)
if not objects.exists(): if not objects.exists():
for mode in ['honor', 'verified', 'professional']: for mode in ['honor', 'verified', 'professional']:
conf = objects.create(mode=mode) conf = objects.create(mode=mode)
...@@ -34,5 +37,5 @@ class Migration(migrations.Migration): ...@@ -34,5 +37,5 @@ class Migration(migrations.Migration):
] ]
operations = [ operations = [
migrations.RunPython(forwards,backwards) migrations.RunPython(forwards, backwards)
] ]
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('certificates', '0007_certificateinvalidation'),
('badges', '0002_data__migrate_assertions'),
]
operations = [
migrations.AlterUniqueTogether(
name='badgeassertion',
unique_together=set([]),
),
migrations.RemoveField(
model_name='badgeassertion',
name='user',
),
migrations.DeleteModel(
name='BadgeImageConfiguration',
),
migrations.DeleteModel(
name='BadgeAssertion',
),
]
...@@ -47,27 +47,28 @@ Eligibility: ...@@ -47,27 +47,28 @@ Eligibility:
""" """
import json import json
import logging import logging
import os
import uuid import uuid
import os
from django.conf import settings
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db import models, transaction from django.db import models, transaction
from django.db.models import Count from django.db.models import Count
from django.db.models.signals import post_save from django.db.models.signals import post_save
from django.dispatch import receiver from django.dispatch import receiver
from django.conf import settings
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from django_extensions.db.fields import CreationDateTimeField from django_extensions.db.fields import CreationDateTimeField
from django_extensions.db.fields.json import JSONField
from model_utils import Choices from model_utils import Choices
from model_utils.models import TimeStampedModel from model_utils.models import TimeStampedModel
from openedx.core.djangoapps.signals.signals import COURSE_CERT_AWARDED from openedx.core.djangoapps.signals.signals import COURSE_CERT_AWARDED
from badges.events.course_complete import course_badge_check
from badges.events.course_meta import completion_check, course_group_check
from config_models.models import ConfigurationModel from config_models.models import ConfigurationModel
from instructor_task.models import InstructorTask from instructor_task.models import InstructorTask
from util.milestones_helpers import fulfill_course_milestone, is_prerequisite_courses_enabled from util.milestones_helpers import fulfill_course_milestone, is_prerequisite_courses_enabled
from xmodule.modulestore.django import modulestore
from xmodule_django.models import CourseKeyField, NoneToEmptyManager from xmodule_django.models import CourseKeyField, NoneToEmptyManager
LOGGER = logging.getLogger(__name__) LOGGER = logging.getLogger(__name__)
...@@ -96,13 +97,15 @@ class CertificateStatuses(object): ...@@ -96,13 +97,15 @@ class CertificateStatuses(object):
error: "error states" error: "error states"
} }
PASSED_STATUSES = (downloadable, generating, regenerating)
@classmethod @classmethod
def is_passing_status(cls, status): def is_passing_status(cls, status):
""" """
Given the status of a certificate, return a boolean indicating whether Given the status of a certificate, return a boolean indicating whether
the student passed the course. the student passed the course.
""" """
return status in [cls.downloadable, cls.generating] return status in cls.PASSED_STATUSES
class CertificateSocialNetworks(object): class CertificateSocialNetworks(object):
...@@ -893,93 +896,6 @@ class CertificateHtmlViewConfiguration(ConfigurationModel): ...@@ -893,93 +896,6 @@ class CertificateHtmlViewConfiguration(ConfigurationModel):
return json_data return json_data
class BadgeAssertion(models.Model):
"""
Tracks badges on our side of the badge baking transaction
"""
user = models.ForeignKey(User)
course_id = CourseKeyField(max_length=255, blank=True, default=None)
# Mode a badge was awarded for.
mode = models.CharField(max_length=100)
data = JSONField()
@property
def image_url(self):
"""
Get the image for this assertion.
"""
return self.data['image']
@property
def assertion_url(self):
"""
Get the public URL for the assertion.
"""
return self.data['json']['id']
class Meta(object):
unique_together = (('course_id', 'user', 'mode'),)
app_label = "certificates"
def validate_badge_image(image):
"""
Validates that a particular image is small enough, of the right type, and square to be a badge.
"""
if image.width != image.height:
raise ValidationError(_(u"The badge image must be square."))
if not image.size < (250 * 1024):
raise ValidationError(_(u"The badge image file size must be less than 250KB."))
class BadgeImageConfiguration(models.Model):
"""
Contains the configuration for badges for a specific mode. The mode
"""
class Meta(object):
app_label = "certificates"
mode = models.CharField(
max_length=125,
help_text=_(u'The course mode for this badge image. For example, "verified" or "honor".'),
unique=True,
)
icon = models.ImageField(
# Actual max is 256KB, but need overhead for badge baking. This should be more than enough.
help_text=_(
u"Badge images must be square PNG files. The file size should be under 250KB."
),
upload_to='badges',
validators=[validate_badge_image]
)
default = models.BooleanField(
default=False,
help_text=_(
u"Set this value to True if you want this image to be the default image for any course modes "
u"that do not have a specified badge image. You can have only one default image."
)
)
def clean(self):
"""
Make sure there's not more than one default.
"""
if self.default and BadgeImageConfiguration.objects.filter(default=True).exclude(id=self.id):
raise ValidationError(_(u"There can be only one default image."))
@classmethod
def image_for_mode(cls, mode):
"""
Get the image for a particular mode.
"""
try:
return cls.objects.get(mode=mode).icon
except cls.DoesNotExist:
# Fall back to default, if there is one.
return cls.objects.get(default=True).icon
class CertificateTemplate(TimeStampedModel): class CertificateTemplate(TimeStampedModel):
"""A set of custom web certificate templates. """A set of custom web certificate templates.
...@@ -1096,24 +1012,25 @@ class CertificateTemplateAsset(TimeStampedModel): ...@@ -1096,24 +1012,25 @@ class CertificateTemplateAsset(TimeStampedModel):
@receiver(COURSE_CERT_AWARDED, sender=GeneratedCertificate) @receiver(COURSE_CERT_AWARDED, sender=GeneratedCertificate)
#pylint: disable=unused-argument # pylint: disable=unused-argument
def create_badge(sender, user, course_key, status, **kwargs): def create_course_badge(sender, user, course_key, status, **kwargs):
"""
Standard signal hook to create course badges when a certificate has been generated.
"""
course_badge_check(user, course_key)
@receiver(COURSE_CERT_AWARDED, sender=GeneratedCertificate)
def create_completion_badge(sender, user, course_key, status, **kwargs): # pylint: disable=unused-argument
"""
Standard signal hook to create 'x courses completed' badges when a certificate has been generated.
"""
completion_check(user)
@receiver(COURSE_CERT_AWARDED, sender=GeneratedCertificate)
def create_course_group_badge(sender, user, course_key, status, **kwargs): # pylint: disable=unused-argument
""" """
Standard signal hook to create badges when a certificate has been generated. Standard signal hook to create badges when a user has completed a prespecified set of courses.
""" """
if not settings.FEATURES.get('ENABLE_OPENBADGES', False): course_group_check(user, course_key)
return
if not modulestore().get_course(course_key).issue_badges:
LOGGER.info("Course is not configured to issue badges.")
return
if BadgeAssertion.objects.filter(user=user, course_id=course_key):
LOGGER.info("Badge already exists for this user on this course.")
# Badge already exists. Skip.
return
# Don't bake a badge until the certificate is available. Prevents user-facing requests from being paused for this
# by making sure it only gets run on the callback during normal workflow.
if not status == CertificateStatuses.downloadable:
return
from .badge_handler import BadgeHandler
handler = BadgeHandler(course_key)
handler.award(user)
# Factories are self documenting # Factories are self documenting
# pylint: disable=missing-docstring # pylint: disable=missing-docstring
import factory
from uuid import uuid4 from uuid import uuid4
from django.core.files.base import ContentFile
from factory.django import DjangoModelFactory, ImageField
from student.models import LinkedInAddToProfileConfiguration from factory.django import DjangoModelFactory
from certificates.models import ( from certificates.models import (
GeneratedCertificate, CertificateStatuses, CertificateHtmlViewConfiguration, CertificateWhitelist, BadgeAssertion, GeneratedCertificate, CertificateStatuses, CertificateHtmlViewConfiguration, CertificateWhitelist,
BadgeImageConfiguration, CertificateInvalidation, CertificateInvalidation,
) )
from student.models import LinkedInAddToProfileConfiguration
class GeneratedCertificateFactory(DjangoModelFactory): class GeneratedCertificateFactory(DjangoModelFactory):
...@@ -44,33 +42,6 @@ class CertificateInvalidationFactory(DjangoModelFactory): ...@@ -44,33 +42,6 @@ class CertificateInvalidationFactory(DjangoModelFactory):
active = True active = True
class BadgeAssertionFactory(DjangoModelFactory):
class Meta(object):
model = BadgeAssertion
mode = 'honor'
data = {
'image': 'http://www.example.com/image.png',
'json': {'id': 'http://www.example.com/assertion.json'},
'issuer': 'http://www.example.com/issuer.json',
}
class BadgeImageConfigurationFactory(DjangoModelFactory):
class Meta(object):
model = BadgeImageConfiguration
mode = 'honor'
icon = factory.LazyAttribute(
lambda _: ContentFile(
ImageField()._make_data( # pylint: disable=protected-access
{'color': 'blue', 'width': 50, 'height': 50, 'format': 'PNG'}
), 'test.png'
)
)
class CertificateHtmlViewConfigurationFactory(DjangoModelFactory): class CertificateHtmlViewConfigurationFactory(DjangoModelFactory):
class Meta(object): class Meta(object):
......
...@@ -8,12 +8,15 @@ from mock import patch ...@@ -8,12 +8,15 @@ from mock import patch
from course_modes.models import CourseMode from course_modes.models import CourseMode
from opaque_keys.edx.locator import CourseLocator from opaque_keys.edx.locator import CourseLocator
from certificates.tests.factories import BadgeAssertionFactory
from badges.events.course_complete import get_completion_badge
from badges.models import BadgeAssertion
from badges.tests.factories import BadgeAssertionFactory, CourseCompleteImageConfigurationFactory
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory, check_mongo_calls from xmodule.modulestore.tests.factories import CourseFactory, check_mongo_calls
from student.tests.factories import UserFactory, CourseEnrollmentFactory from student.tests.factories import UserFactory, CourseEnrollmentFactory
from certificates.management.commands import resubmit_error_certificates, regenerate_user, ungenerated_certs from certificates.management.commands import resubmit_error_certificates, regenerate_user, ungenerated_certs
from certificates.models import GeneratedCertificate, CertificateStatuses, BadgeAssertion from certificates.models import GeneratedCertificate, CertificateStatuses
class CertificateManagementTest(ModuleStoreTestCase): class CertificateManagementTest(ModuleStoreTestCase):
...@@ -30,6 +33,7 @@ class CertificateManagementTest(ModuleStoreTestCase): ...@@ -30,6 +33,7 @@ class CertificateManagementTest(ModuleStoreTestCase):
CourseFactory.create() CourseFactory.create()
for __ in range(3) for __ in range(3)
] ]
CourseCompleteImageConfigurationFactory.create()
def _create_cert(self, course_key, user, status, mode=CourseMode.HONOR): def _create_cert(self, course_key, user, status, mode=CourseMode.HONOR):
"""Create a certificate entry. """ """Create a certificate entry. """
...@@ -146,6 +150,7 @@ class ResubmitErrorCertificatesTest(CertificateManagementTest): ...@@ -146,6 +150,7 @@ class ResubmitErrorCertificatesTest(CertificateManagementTest):
self._assert_cert_status(phantom_course, self.user, CertificateStatuses.error) self._assert_cert_status(phantom_course, self.user, CertificateStatuses.error)
@ddt.ddt
@attr('shard_1') @attr('shard_1')
class RegenerateCertificatesTest(CertificateManagementTest): class RegenerateCertificatesTest(CertificateManagementTest):
""" """
...@@ -160,19 +165,23 @@ class RegenerateCertificatesTest(CertificateManagementTest): ...@@ -160,19 +165,23 @@ class RegenerateCertificatesTest(CertificateManagementTest):
super(RegenerateCertificatesTest, self).setUp() super(RegenerateCertificatesTest, self).setUp()
self.course = self.courses[0] self.course = self.courses[0]
@ddt.data(True, False)
@override_settings(CERT_QUEUE='test-queue') @override_settings(CERT_QUEUE='test-queue')
@patch('certificates.api.XQueueCertInterface', spec=True) @patch('certificates.api.XQueueCertInterface', spec=True)
def test_clear_badge(self, xqueue): def test_clear_badge(self, issue_badges, xqueue):
""" """
Given that I have a user with a badge Given that I have a user with a badge
If I run regeneration for a user If I run regeneration for a user
Then certificate generation will be requested Then certificate generation will be requested
And the badge will be deleted And the badge will be deleted if badge issuing is enabled
""" """
key = self.course.location.course_key key = self.course.location.course_key
BadgeAssertionFactory(user=self.user, course_id=key, data={})
self._create_cert(key, self.user, CertificateStatuses.downloadable) self._create_cert(key, self.user, CertificateStatuses.downloadable)
self.assertTrue(BadgeAssertion.objects.filter(user=self.user, course_id=key)) badge_class = get_completion_badge(key, self.user)
BadgeAssertionFactory(badge_class=badge_class, user=self.user)
self.assertTrue(BadgeAssertion.objects.filter(user=self.user, badge_class=badge_class))
self.course.issue_badges = issue_badges
self.store.update_item(self.course, None)
self._run_command( self._run_command(
username=self.user.email, course=unicode(key), noop=False, insecure=False, template_file=None, username=self.user.email, course=unicode(key), noop=False, insecure=False, template_file=None,
grade_value=None grade_value=None
...@@ -185,7 +194,9 @@ class RegenerateCertificatesTest(CertificateManagementTest): ...@@ -185,7 +194,9 @@ class RegenerateCertificatesTest(CertificateManagementTest):
template_file=None, template_file=None,
generate_pdf=True generate_pdf=True
) )
self.assertFalse(BadgeAssertion.objects.filter(user=self.user, course_id=key)) self.assertEquals(
bool(BadgeAssertion.objects.filter(user=self.user, badge_class=badge_class)), not issue_badges
)
@override_settings(CERT_QUEUE='test-queue') @override_settings(CERT_QUEUE='test-queue')
@patch('capa.xqueue_interface.XQueueInterface.send_to_queue', spec=True) @patch('capa.xqueue_interface.XQueueInterface.send_to_queue', spec=True)
......
"""Tests for certificate Django models. """ """Tests for certificate Django models. """
from django.conf import settings from django.conf import settings
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.core.files.images import ImageFile
from django.core.files.uploadedfile import SimpleUploadedFile from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import TestCase from django.test import TestCase
from django.test.utils import override_settings from django.test.utils import override_settings
...@@ -13,7 +12,6 @@ from certificates.models import ( ...@@ -13,7 +12,6 @@ from certificates.models import (
ExampleCertificateSet, ExampleCertificateSet,
CertificateHtmlViewConfiguration, CertificateHtmlViewConfiguration,
CertificateTemplateAsset, CertificateTemplateAsset,
BadgeImageConfiguration,
EligibleCertificateManager, EligibleCertificateManager,
GeneratedCertificate, GeneratedCertificate,
CertificateStatuses, CertificateStatuses,
...@@ -168,55 +166,6 @@ class CertificateHtmlViewConfigurationTest(TestCase): ...@@ -168,55 +166,6 @@ class CertificateHtmlViewConfigurationTest(TestCase):
@attr('shard_1') @attr('shard_1')
class BadgeImageConfigurationTest(TestCase):
"""
Test the validation features of BadgeImageConfiguration.
"""
def get_image(self, name):
"""
Get one of the test images from the test data directory.
"""
return ImageFile(open(TEST_DATA_ROOT / 'badges' / name + '.png'))
def create_clean(self, file_obj):
"""
Shortcut to create a BadgeImageConfiguration with a specific file.
"""
BadgeImageConfiguration(mode='honor', icon=file_obj).full_clean()
def test_good_image(self):
"""
Verify that saving a valid badge image is no problem.
"""
good = self.get_image('good')
BadgeImageConfiguration(mode='honor', icon=good).full_clean()
def test_unbalanced_image(self):
"""
Verify that setting an image with an uneven width and height raises an error.
"""
unbalanced = ImageFile(self.get_image('unbalanced'))
self.assertRaises(ValidationError, self.create_clean, unbalanced)
def test_large_image(self):
"""
Verify that setting an image that is too big raises an error.
"""
large = self.get_image('large')
self.assertRaises(ValidationError, self.create_clean, large)
def test_no_double_default(self):
"""
Verify that creating two configurations as default is not permitted.
"""
BadgeImageConfiguration(mode='test', icon=self.get_image('good'), default=True).save()
self.assertRaises(
ValidationError,
BadgeImageConfiguration(mode='test2', icon=self.get_image('good'), default=True).full_clean
)
@attr('shard_1')
class CertificateTemplateAssetTest(TestCase): class CertificateTemplateAssetTest(TestCase):
""" """
Test Assets are uploading/saving successfully for CertificateTemplateAsset. Test Assets are uploading/saving successfully for CertificateTemplateAsset.
......
"""Tests for certificates views. """ """Tests for certificates views. """
import json import json
import ddt
from uuid import uuid4 from uuid import uuid4
from nose.plugins.attrib import attr
from mock import patch
import ddt
from django.conf import settings from django.conf import settings
from django.core.cache import cache from django.core.cache import cache
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from django.test import TestCase from django.test import TestCase
from django.test.client import Client from django.test.client import Client
from django.test.utils import override_settings from django.test.utils import override_settings
from nose.plugins.attrib import attr
from opaque_keys.edx.locator import CourseLocator from opaque_keys.edx.locator import CourseLocator
from openedx.core.lib.tests.assertions.events import assert_event_matches
from student.tests.factories import UserFactory
from track.tests import EventTrackingTestCase
from xmodule.modulestore.tests.factories import CourseFactory
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from util.testing import UrlResetMixin
from certificates.api import get_certificate_url from certificates.api import get_certificate_url
from certificates.models import ( from certificates.models import (
...@@ -28,10 +20,9 @@ from certificates.models import ( ...@@ -28,10 +20,9 @@ from certificates.models import (
GeneratedCertificate, GeneratedCertificate,
CertificateHtmlViewConfiguration, CertificateHtmlViewConfiguration,
) )
from student.tests.factories import UserFactory
from certificates.tests.factories import ( from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
BadgeAssertionFactory, from xmodule.modulestore.tests.factories import CourseFactory
)
FEATURES_WITH_CERTS_ENABLED = settings.FEATURES.copy() FEATURES_WITH_CERTS_ENABLED = settings.FEATURES.copy()
FEATURES_WITH_CERTS_ENABLED['CERTIFICATES_HTML_VIEW'] = True FEATURES_WITH_CERTS_ENABLED['CERTIFICATES_HTML_VIEW'] = True
...@@ -342,51 +333,3 @@ class MicrositeCertificatesViewsTests(ModuleStoreTestCase): ...@@ -342,51 +333,3 @@ class MicrositeCertificatesViewsTests(ModuleStoreTestCase):
self.assertNotIn('platform_microsite', response.content) self.assertNotIn('platform_microsite', response.content)
self.assertNotIn('http://www.microsite.org', response.content) self.assertNotIn('http://www.microsite.org', response.content)
self.assertNotIn('This should not survive being overwritten by static content', response.content) self.assertNotIn('This should not survive being overwritten by static content', response.content)
class TrackShareRedirectTest(UrlResetMixin, ModuleStoreTestCase, EventTrackingTestCase):
"""
Verifies the badge image share event is sent out.
"""
@patch.dict(settings.FEATURES, {"ENABLE_OPENBADGES": True})
def setUp(self):
super(TrackShareRedirectTest, self).setUp('certificates.urls')
self.client = Client()
self.course = CourseFactory.create(
org='testorg', number='run1', display_name='trackable course'
)
self.assertion = BadgeAssertionFactory(
user=self.user, course_id=self.course.id, data={
'image': 'http://www.example.com/image.png',
'json': {'id': 'http://www.example.com/assertion.json'},
'issuer': 'http://www.example.com/issuer.json'
},
)
def test_social_event_sent(self):
test_url = '/certificates/badge_share_tracker/{}/social_network/{}/'.format(
unicode(self.course.id),
self.user.username,
)
self.recreate_tracker()
response = self.client.get(test_url)
self.assertEqual(response.status_code, 302)
self.assertEqual(response['Location'], 'http://www.example.com/image.png')
assert_event_matches(
{
'name': 'edx.badge.assertion.shared',
'data': {
'course_id': 'testorg/run1/trackable_course',
'social_network': 'social_network',
# pylint: disable=no-member
'assertion_id': self.assertion.id,
'assertion_json_url': 'http://www.example.com/assertion.json',
'assertion_image_url': 'http://www.example.com/image.png',
'user_id': self.user.id,
'issuer': 'http://www.example.com/issuer.json',
'enrollment_mode': 'honor'
},
},
self.get_event()
)
...@@ -3,7 +3,6 @@ ...@@ -3,7 +3,6 @@
import json import json
import ddt import ddt
import mock
from uuid import uuid4 from uuid import uuid4
from nose.plugins.attrib import attr from nose.plugins.attrib import attr
from mock import patch from mock import patch
...@@ -16,6 +15,8 @@ from django.test.client import Client ...@@ -16,6 +15,8 @@ from django.test.client import Client
from django.test.utils import override_settings from django.test.utils import override_settings
from course_modes.models import CourseMode from course_modes.models import CourseMode
from badges.events.course_complete import get_completion_badge
from badges.tests.factories import BadgeAssertionFactory, CourseCompleteImageConfigurationFactory, BadgeClassFactory
from openedx.core.lib.tests.assertions.events import assert_event_matches from openedx.core.lib.tests.assertions.events import assert_event_matches
from student.tests.factories import UserFactory, CourseEnrollmentFactory from student.tests.factories import UserFactory, CourseEnrollmentFactory
from student.roles import CourseStaffRole from student.roles import CourseStaffRole
...@@ -36,15 +37,15 @@ from certificates.models import ( ...@@ -36,15 +37,15 @@ from certificates.models import (
from certificates.tests.factories import ( from certificates.tests.factories import (
CertificateHtmlViewConfigurationFactory, CertificateHtmlViewConfigurationFactory,
LinkedInAddToProfileConfigurationFactory, LinkedInAddToProfileConfigurationFactory,
BadgeAssertionFactory,
GeneratedCertificateFactory, GeneratedCertificateFactory,
) )
from util import organizations_helpers as organizations_api from util import organizations_helpers as organizations_api
from django.test.client import RequestFactory from django.test.client import RequestFactory
import urllib
FEATURES_WITH_CERTS_ENABLED = settings.FEATURES.copy() FEATURES_WITH_CERTS_ENABLED = settings.FEATURES.copy()
FEATURES_WITH_CERTS_ENABLED['CERTIFICATES_HTML_VIEW'] = True FEATURES_WITH_CERTS_ENABLED['CERTIFICATES_HTML_VIEW'] = True
FEATURES_WITH_BADGES_ENABLED = FEATURES_WITH_CERTS_ENABLED.copy()
FEATURES_WITH_BADGES_ENABLED['ENABLE_OPENBADGES'] = True
FEATURES_WITH_CERTS_DISABLED = settings.FEATURES.copy() FEATURES_WITH_CERTS_DISABLED = settings.FEATURES.copy()
FEATURES_WITH_CERTS_DISABLED['CERTIFICATES_HTML_VIEW'] = False FEATURES_WITH_CERTS_DISABLED['CERTIFICATES_HTML_VIEW'] = False
...@@ -105,6 +106,7 @@ class CertificatesViewsTests(ModuleStoreTestCase, EventTrackingTestCase): ...@@ -105,6 +106,7 @@ class CertificatesViewsTests(ModuleStoreTestCase, EventTrackingTestCase):
) )
CertificateHtmlViewConfigurationFactory.create() CertificateHtmlViewConfigurationFactory.create()
LinkedInAddToProfileConfigurationFactory.create() LinkedInAddToProfileConfigurationFactory.create()
CourseCompleteImageConfigurationFactory.create()
def _add_course_certificates(self, count=1, signatory_count=0, is_active=True): def _add_course_certificates(self, count=1, signatory_count=0, is_active=True):
""" """
...@@ -333,7 +335,27 @@ class CertificatesViewsTests(ModuleStoreTestCase, EventTrackingTestCase): ...@@ -333,7 +335,27 @@ class CertificatesViewsTests(ModuleStoreTestCase, EventTrackingTestCase):
) )
self.assertIn('logo_test1.png', response.content) self.assertIn('logo_test1.png', response.content)
@override_settings(FEATURES=FEATURES_WITH_CERTS_ENABLED) @ddt.data(True, False)
@patch('certificates.views.webview.get_completion_badge')
@override_settings(FEATURES=FEATURES_WITH_BADGES_ENABLED)
def test_fetch_badge_info(self, issue_badges, mock_get_completion_badge):
"""
Test: Fetch badge class info if badges are enabled.
"""
badge_class = BadgeClassFactory(course_id=self.course_id, mode=self.cert.mode)
mock_get_completion_badge.return_value = badge_class
self._add_course_certificates(count=1, signatory_count=1, is_active=True)
test_url = get_certificate_url(course_id=self.cert.course_id, uuid=self.cert.verify_uuid)
response = self.client.get(test_url)
self.assertEqual(response.status_code, 200)
if issue_badges:
mock_get_completion_badge.assertCalled()
else:
mock_get_completion_badge.assertNotCalled()
@override_settings(FEATURES=FEATURES_WITH_BADGES_ENABLED)
@patch.dict("django.conf.settings.SOCIAL_SHARING_SETTINGS", { @patch.dict("django.conf.settings.SOCIAL_SHARING_SETTINGS", {
"CERTIFICATE_TWITTER": True, "CERTIFICATE_TWITTER": True,
"CERTIFICATE_FACEBOOK": True, "CERTIFICATE_FACEBOOK": True,
...@@ -370,8 +392,9 @@ class CertificatesViewsTests(ModuleStoreTestCase, EventTrackingTestCase): ...@@ -370,8 +392,9 @@ class CertificatesViewsTests(ModuleStoreTestCase, EventTrackingTestCase):
test_org = organizations_api.add_organization(organization_data=test_organization_data) test_org = organizations_api.add_organization(organization_data=test_organization_data)
organizations_api.add_organization_course(organization_data=test_org, course_id=unicode(self.course.id)) organizations_api.add_organization_course(organization_data=test_org, course_id=unicode(self.course.id))
self._add_course_certificates(count=1, signatory_count=1, is_active=True) self._add_course_certificates(count=1, signatory_count=1, is_active=True)
badge_class = get_completion_badge(course_id=self.course_id, user=self.user)
BadgeAssertionFactory.create( BadgeAssertionFactory.create(
user=self.user, course_id=self.course_id, user=self.user, badge_class=badge_class,
) )
self.course.cert_html_view_overrides = { self.course.cert_html_view_overrides = {
"logo_src": "/static/certificates/images/course_override_logo.png" "logo_src": "/static/certificates/images/course_override_logo.png"
...@@ -812,8 +835,15 @@ class CertificatesViewsTests(ModuleStoreTestCase, EventTrackingTestCase): ...@@ -812,8 +835,15 @@ class CertificatesViewsTests(ModuleStoreTestCase, EventTrackingTestCase):
) )
test_url = '{}?evidence_visit=1'.format(cert_url) test_url = '{}?evidence_visit=1'.format(cert_url)
self.recreate_tracker() self.recreate_tracker()
badge_class = get_completion_badge(self.course_id, self.user)
assertion = BadgeAssertionFactory.create( assertion = BadgeAssertionFactory.create(
user=self.user, course_id=self.course_id, user=self.user, badge_class=badge_class,
backend='DummyBackend',
image_url='http://www.example.com/image.png',
assertion_url='http://www.example.com/assertion.json',
data={
'issuer': 'http://www.example.com/issuer.json',
}
) )
response = self.client.get(test_url) response = self.client.get(test_url)
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
...@@ -823,6 +853,10 @@ class CertificatesViewsTests(ModuleStoreTestCase, EventTrackingTestCase): ...@@ -823,6 +853,10 @@ class CertificatesViewsTests(ModuleStoreTestCase, EventTrackingTestCase):
'data': { 'data': {
'course_id': 'testorg/run1/refundable_course', 'course_id': 'testorg/run1/refundable_course',
'assertion_id': assertion.id, 'assertion_id': assertion.id,
'badge_generator': u'DummyBackend',
'badge_name': u'refundable course',
'issuing_component': u'',
'badge_slug': u'testorgrun1refundable_course_honor_432f164',
'assertion_json_url': 'http://www.example.com/assertion.json', 'assertion_json_url': 'http://www.example.com/assertion.json',
'assertion_image_url': 'http://www.example.com/image.png', 'assertion_image_url': 'http://www.example.com/image.png',
'user_id': self.user.id, 'user_id': self.user.id,
......
...@@ -6,10 +6,11 @@ from mock import patch ...@@ -6,10 +6,11 @@ from mock import patch
from django.conf import settings from django.conf import settings
from nose.plugins.attrib import attr from nose.plugins.attrib import attr
from badges.tests.factories import CourseCompleteImageConfigurationFactory
from xmodule.modulestore.tests.factories import CourseFactory from xmodule.modulestore.tests.factories import CourseFactory
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from student.tests.factories import UserFactory from student.tests.factories import UserFactory, CourseEnrollmentFactory
from certificates.models import ( from certificates.models import (
CertificateStatuses, CertificateStatuses,
GeneratedCertificate, GeneratedCertificate,
...@@ -113,18 +114,18 @@ class CertificatesModelTest(ModuleStoreTestCase, MilestonesTestCaseMixin): ...@@ -113,18 +114,18 @@ class CertificatesModelTest(ModuleStoreTestCase, MilestonesTestCaseMixin):
self.assertEqual(completed_milestones[0]['namespace'], unicode(pre_requisite_course.id)) self.assertEqual(completed_milestones[0]['namespace'], unicode(pre_requisite_course.id))
@patch.dict(settings.FEATURES, {'ENABLE_OPENBADGES': True}) @patch.dict(settings.FEATURES, {'ENABLE_OPENBADGES': True})
@patch('certificates.badge_handler.BadgeHandler', spec=True) @patch('badges.backends.badgr.BadgrBackend', spec=True)
def test_badge_callback(self, handler): def test_badge_callback(self, handler):
student = UserFactory() student = UserFactory()
course = CourseFactory.create(org='edx', number='998', display_name='Test Course', issue_badges=True) course = CourseFactory.create(org='edx', number='998', display_name='Test Course', issue_badges=True)
CourseCompleteImageConfigurationFactory()
CourseEnrollmentFactory(user=student, course_id=course.location.course_key, mode='honor')
cert = GeneratedCertificateFactory.create( cert = GeneratedCertificateFactory.create(
user=student, user=student,
course_id=course.id, course_id=course.id,
status=CertificateStatuses.generating, status=CertificateStatuses.generating,
mode='verified' mode='verified'
) )
# Check return value since class instance will be stored there.
self.assertFalse(handler.return_value.award.called)
cert.status = CertificateStatuses.downloadable cert.status = CertificateStatuses.downloadable
cert.save() cert.save()
self.assertTrue(handler.return_value.award.called) self.assertTrue(handler.return_value.award.called)
...@@ -31,15 +31,3 @@ urlpatterns = patterns( ...@@ -31,15 +31,3 @@ urlpatterns = patterns(
url(r'regenerate', views.regenerate_certificate_for_user, name="regenerate_certificate_for_user"), url(r'regenerate', views.regenerate_certificate_for_user, name="regenerate_certificate_for_user"),
url(r'generate', views.generate_certificate_for_user, name="generate_certificate_for_user"), url(r'generate', views.generate_certificate_for_user, name="generate_certificate_for_user"),
) )
if settings.FEATURES.get("ENABLE_OPENBADGES", False):
urlpatterns += (
url(
r'^badge_share_tracker/{}/(?P<network>[^/]+)/(?P<student_username>[^/]+)/$'.format(
settings.COURSE_ID_PATTERN
),
views.track_share_redirect,
name='badge_share_tracker'
),
)
...@@ -5,4 +5,3 @@ Aggregate all views exposed by the certificates app. ...@@ -5,4 +5,3 @@ Aggregate all views exposed by the certificates app.
from .xqueue import * from .xqueue import *
from .support import * from .support import *
from .webview import * from .webview import *
from .badges import *
"""
Certificate views for open badges.
"""
from django.shortcuts import redirect, get_object_or_404
from opaque_keys.edx.locator import CourseLocator
from util.views import ensure_valid_course_key
from eventtracking import tracker
from certificates.models import BadgeAssertion
@ensure_valid_course_key
def track_share_redirect(request__unused, course_id, network, student_username):
"""
Tracks when a user downloads a badge for sharing.
"""
course_id = CourseLocator.from_string(course_id)
assertion = get_object_or_404(BadgeAssertion, user__username=student_username, course_id=course_id)
tracker.emit(
'edx.badge.assertion.shared', {
'course_id': unicode(course_id),
'social_network': network,
'assertion_id': assertion.id,
'assertion_json_url': assertion.data['json']['id'],
'assertion_image_url': assertion.image_url,
'user_id': assertion.user.id,
'enrollment_mode': assertion.mode,
'issuer': assertion.data['issuer'],
}
)
return redirect(assertion.image_url)
...@@ -13,8 +13,9 @@ from django.http import HttpResponse, Http404 ...@@ -13,8 +13,9 @@ from django.http import HttpResponse, Http404
from django.template import RequestContext from django.template import RequestContext
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
from django.utils.encoding import smart_str from django.utils.encoding import smart_str
from django.core.urlresolvers import reverse
from badges.events.course_complete import get_completion_badge
from badges.utils import badges_enabled
from courseware.access import has_access from courseware.access import has_access
from edxmako.shortcuts import render_to_response from edxmako.shortcuts import render_to_response
from edxmako.template import Template from edxmako.template import Template
...@@ -42,9 +43,8 @@ from certificates.models import ( ...@@ -42,9 +43,8 @@ from certificates.models import (
GeneratedCertificate, GeneratedCertificate,
CertificateStatuses, CertificateStatuses,
CertificateHtmlViewConfiguration, CertificateHtmlViewConfiguration,
CertificateSocialNetworks, CertificateSocialNetworks)
BadgeAssertion
)
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
...@@ -355,21 +355,41 @@ def _track_certificate_events(request, context, course, user, user_certificate): ...@@ -355,21 +355,41 @@ def _track_certificate_events(request, context, course, user, user_certificate):
""" """
Tracks web certificate view related events. Tracks web certificate view related events.
""" """
badge = context['badge']
# Badge Request Event Tracking Logic # Badge Request Event Tracking Logic
if 'evidence_visit' in request.GET and badge: course_key = course.location.course_key
tracker.emit(
'edx.badge.assertion.evidence_visited', if 'evidence_visit' in request.GET:
{ badge_class = get_completion_badge(course_key, user)
'user_id': user.id, if not badge_class:
'course_id': unicode(course.id), log.warning('Visit to evidence URL for badge, but badges not configured for course "%s"', course_key)
'enrollment_mode': badge.mode, badges = []
'assertion_id': badge.id, else:
'assertion_image_url': badge.data['image'], badges = badge_class.get_for_user(user)
'assertion_json_url': badge.data['json']['id'], if badges:
'issuer': badge.data['issuer'], # There should only ever be one of these.
} badge = badges[0]
) tracker.emit(
'edx.badge.assertion.evidence_visited',
{
'badge_name': badge.badge_class.display_name,
'badge_slug': badge.badge_class.slug,
'badge_generator': badge.backend,
'issuing_component': badge.badge_class.issuing_component,
'user_id': user.id,
'course_id': unicode(course_key),
'enrollment_mode': badge.badge_class.mode,
'assertion_id': badge.id,
'assertion_image_url': badge.image_url,
'assertion_json_url': badge.assertion_url,
'issuer': badge.data.get('issuer'),
}
)
else:
log.warn(
"Could not find badge for %s on course %s.",
user.id,
course_key,
)
# track certificate evidence_visited event for analytics when certificate_user and accessing_user are different # track certificate evidence_visited event for analytics when certificate_user and accessing_user are different
if request.user and request.user.id != user.id: if request.user and request.user.id != user.id:
...@@ -425,10 +445,11 @@ def _update_badge_context(context, course, user): ...@@ -425,10 +445,11 @@ def _update_badge_context(context, course, user):
""" """
Updates context with badge info. Updates context with badge info.
""" """
try: badge = None
badge = BadgeAssertion.objects.get(user=user, course_id=course.location.course_key) if badges_enabled() and course.issue_badges:
except BadgeAssertion.DoesNotExist: badges = get_completion_badge(course.location.course_key, user).get_for_user(user)
badge = None if badges:
badge = badges[0]
context['badge'] = badge context['badge'] = badge
......
...@@ -6,6 +6,8 @@ import re ...@@ -6,6 +6,8 @@ import re
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from django.conf import settings from django.conf import settings
from badges.service import BadgingService
from badges.utils import badges_enabled
from openedx.core.djangoapps.user_api.course_tag import api as user_course_tag_api from openedx.core.djangoapps.user_api.course_tag import api as user_course_tag_api
from request_cache.middleware import RequestCache from request_cache.middleware import RequestCache
import xblock.reference.plugins import xblock.reference.plugins
...@@ -211,8 +213,11 @@ class LmsModuleSystem(ModuleSystem): # pylint: disable=abstract-method ...@@ -211,8 +213,11 @@ class LmsModuleSystem(ModuleSystem): # pylint: disable=abstract-method
track_function=kwargs.get('track_function', None), track_function=kwargs.get('track_function', None),
cache=request_cache_dict cache=request_cache_dict
) )
store = modulestore()
services['settings'] = SettingsService() services['settings'] = SettingsService()
services['user_tags'] = UserTagsService(self) services['user_tags'] = UserTagsService(self)
if badges_enabled():
services['badging'] = BadgingService(course_id=kwargs.get('course_id'), modulestore=store)
self.request_token = kwargs.pop('request_token', None) self.request_token = kwargs.pop('request_token', None)
super(LmsModuleSystem, self).__init__(**kwargs) super(LmsModuleSystem, self).__init__(**kwargs)
......
...@@ -5,17 +5,25 @@ Tests of the LMS XBlock Runtime and associated utilities ...@@ -5,17 +5,25 @@ Tests of the LMS XBlock Runtime and associated utilities
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.conf import settings from django.conf import settings
from ddt import ddt, data from ddt import ddt, data
from mock import Mock from django.test import TestCase
from unittest import TestCase from mock import Mock, patch
from urlparse import urlparse from urlparse import urlparse
from opaque_keys.edx.keys import CourseKey
from opaque_keys.edx.locations import BlockUsageLocator, CourseLocator, SlashSeparatedCourseKey from opaque_keys.edx.locations import BlockUsageLocator, CourseLocator, SlashSeparatedCourseKey
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from badges.tests.factories import BadgeClassFactory
from badges.tests.test_models import get_image
from lms.djangoapps.lms_xblock.runtime import quote_slashes, unquote_slashes, LmsModuleSystem from lms.djangoapps.lms_xblock.runtime import quote_slashes, unquote_slashes, LmsModuleSystem
from xblock.fields import ScopeIds from xblock.fields import ScopeIds
from xmodule.modulestore.django import ModuleI18nService from xmodule.modulestore.django import ModuleI18nService
from xmodule.modulestore.tests.factories import CourseFactory
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xblock.exceptions import NoSuchServiceError from xblock.exceptions import NoSuchServiceError
from student.tests.factories import UserFactory
from xmodule.modulestore.tests.factories import CourseFactory
TEST_STRINGS = [ TEST_STRINGS = [
'', '',
'foobar', 'foobar',
...@@ -142,9 +150,7 @@ class TestUserServiceAPI(TestCase): ...@@ -142,9 +150,7 @@ class TestUserServiceAPI(TestCase):
def setUp(self): def setUp(self):
super(TestUserServiceAPI, self).setUp() super(TestUserServiceAPI, self).setUp()
self.course_id = SlashSeparatedCourseKey("org", "course", "run") self.course_id = SlashSeparatedCourseKey("org", "course", "run")
self.user = UserFactory.create()
self.user = User(username='runtime_robot', email='runtime_robot@edx.org', password='test', first_name='Robot')
self.user.save()
def mock_get_real_user(_anon_id): def mock_get_real_user(_anon_id):
"""Just returns the test user""" """Just returns the test user"""
...@@ -187,6 +193,72 @@ class TestUserServiceAPI(TestCase): ...@@ -187,6 +193,72 @@ class TestUserServiceAPI(TestCase):
self.runtime.service(self.mock_block, 'user_tags').get_tag('fake_scope', self.key) self.runtime.service(self.mock_block, 'user_tags').get_tag('fake_scope', self.key)
@ddt
class TestBadgingService(ModuleStoreTestCase):
"""Test the badging service interface"""
def setUp(self):
super(TestBadgingService, self).setUp()
self.course_id = CourseKey.from_string('course-v1:org+course+run')
self.mock_block = Mock()
self.mock_block.service_declaration.return_value = 'needs'
def create_runtime(self):
"""
Create the testing runtime.
"""
def mock_get_real_user(_anon_id):
"""Just returns the test user"""
return self.user
return LmsModuleSystem(
static_url='/static',
track_function=Mock(),
get_module=Mock(),
render_template=Mock(),
replace_urls=str,
course_id=self.course_id,
get_real_user=mock_get_real_user,
descriptor_runtime=Mock(),
)
@patch.dict(settings.FEATURES, {'ENABLE_OPENBADGES': True})
def test_service_rendered(self):
runtime = self.create_runtime()
self.assertTrue(runtime.service(self.mock_block, 'badging'))
@patch.dict(settings.FEATURES, {'ENABLE_OPENBADGES': False})
def test_no_service_rendered(self):
runtime = self.create_runtime()
self.assertFalse(runtime.service(self.mock_block, 'badging'))
@data(True, False)
@patch.dict(settings.FEATURES, {'ENABLE_OPENBADGES': True})
def test_course_badges_toggle(self, toggle):
self.course_id = CourseFactory.create(metadata={'issue_badges': toggle}).location.course_key
runtime = self.create_runtime()
self.assertIs(runtime.service(self.mock_block, 'badging').course_badges_enabled, toggle)
@patch.dict(settings.FEATURES, {'ENABLE_OPENBADGES': True})
def test_get_badge_class(self):
runtime = self.create_runtime()
badge_service = runtime.service(self.mock_block, 'badging')
premade_badge_class = BadgeClassFactory.create()
# Ignore additional parameters. This class already exists.
# We should get back the first class we created, rather than a new one.
badge_class = badge_service.get_badge_class(
slug='test_slug', issuing_component='test_component', description='Attempted override',
criteria='test', display_name='Testola', image_file_handle=get_image('good')
)
# These defaults are set on the factory.
self.assertEqual(badge_class.criteria, 'https://example.com/syllabus')
self.assertEqual(badge_class.display_name, 'Test Badge')
self.assertEqual(badge_class.description, "Yay! It's a test badge.")
# File name won't always be the same.
self.assertEqual(badge_class.image.path, premade_badge_class.image.path)
class TestI18nService(ModuleStoreTestCase): class TestI18nService(ModuleStoreTestCase):
""" Test ModuleI18nService """ """ Test ModuleI18nService """
......
""" Views for a student's profile information. """ """ Views for a student's profile information. """
from django.conf import settings from django.conf import settings
from django.contrib.auth.decorators import login_required
from django.core.exceptions import ObjectDoesNotExist from django.core.exceptions import ObjectDoesNotExist
from django_countries import countries
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from django.contrib.auth.decorators import login_required
from django.http import Http404 from django.http import Http404
from django.views.decorators.http import require_http_methods from django.views.decorators.http import require_http_methods
from django_countries import countries
from django.contrib.staticfiles.storage import staticfiles_storage
from edxmako.shortcuts import render_to_response from badges.utils import badges_enabled
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.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.errors import UserNotFound, UserNotAuthorized
from openedx.core.djangoapps.user_api.preferences.api import get_user_preferences from openedx.core.djangoapps.user_api.preferences.api import get_user_preferences
from student.models import User from student.models import User
from microsite_configuration import microsite
@login_required @login_required
...@@ -87,9 +87,17 @@ def learner_profile_context(request, profile_username, user_is_staff): ...@@ -87,9 +87,17 @@ def learner_profile_context(request, profile_username, user_is_staff):
'has_preferences_access': (logged_in_user.username == profile_username or user_is_staff), 'has_preferences_access': (logged_in_user.username == profile_username or user_is_staff),
'own_profile': own_profile, 'own_profile': own_profile,
'country_options': list(countries), 'country_options': list(countries),
'find_courses_url': marketing_link('COURSES'),
'language_options': settings.ALL_LANGUAGES, 'language_options': settings.ALL_LANGUAGES,
'badges_logo': staticfiles_storage.url('certificates/images/backpack-logo.png'),
'badges_icon': staticfiles_storage.url('certificates/images/ico-mozillaopenbadges.png'),
'backpack_ui_img': staticfiles_storage.url('certificates/images/backpack-ui.png'),
'platform_name': microsite.get_value('platform_name', settings.PLATFORM_NAME), 'platform_name': microsite.get_value('platform_name', settings.PLATFORM_NAME),
}, },
'disable_courseware_js': True, 'disable_courseware_js': True,
} }
if badges_enabled():
context['data']['badges_api_url'] = reverse("badges_api:user_assertions", kwargs={'username': profile_username})
return context return context
...@@ -10,13 +10,13 @@ ...@@ -10,13 +10,13 @@
BaseCollection.prototype.initialize.call(this, options); BaseCollection.prototype.initialize.call(this, options);
this.server_api = _.extend( this.server_api = _.extend(
this.server_api,
{ {
topic_id: this.topic_id = options.topic_id, topic_id: this.topic_id = options.topic_id,
expand: 'user', expand: 'user',
course_id: function () { return encodeURIComponent(self.course_id); }, course_id: function () { return encodeURIComponent(self.course_id); },
order_by: function () { return self.searchString ? '' : this.sortField; } 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 delete this.server_api.sort_order; // Sort order is not specified for the Team API
......
...@@ -11,11 +11,11 @@ ...@@ -11,11 +11,11 @@
this.perPage = topics.results.length; this.perPage = topics.results.length;
this.server_api = _.extend( this.server_api = _.extend(
this.server_api,
{ {
course_id: function () { return encodeURIComponent(self.course_id); }, course_id: function () { return encodeURIComponent(self.course_id); },
order_by: function () { return this.sortField; } 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 delete this.server_api['sort_order']; // Sort order is not specified for the Team API
......
...@@ -5,7 +5,8 @@ ...@@ -5,7 +5,8 @@
return function (options) { return function (options) {
var teamsTab = new TeamsTabView({ var teamsTab = new TeamsTabView({
el: $('.teams-content'), el: $('.teams-content'),
context: options context: options,
viewLabel: gettext("Teams")
}); });
teamsTab.start(); teamsTab.start();
}; };
......
...@@ -15,6 +15,8 @@ ...@@ -15,6 +15,8 @@
text: gettext('All teams') text: gettext('All teams')
}, },
paginationLabel: gettext('Teams Pagination'),
initialize: function (options) { initialize: function (options) {
this.context = options.context; this.context = options.context;
this.itemViewClass = TeamCardView.extend({ this.itemViewClass = TeamCardView.extend({
......
...@@ -5,7 +5,7 @@ ...@@ -5,7 +5,7 @@
'use strict'; 'use strict';
define([ define([
'js/components/tabbed/views/tabbed_view', 'common/js/components/views/tabbed_view',
'teams/js/utils/team_analytics' 'teams/js/utils/team_analytics'
], function (TabbedView, TeamAnalytics) { ], function (TabbedView, TeamAnalytics) {
var TeamsTabbedView = TabbedView.extend({ var TeamsTabbedView = TabbedView.extend({
......
...@@ -77,37 +77,12 @@ def team_post_save_callback(sender, instance, **kwargs): # pylint: disable=unus ...@@ -77,37 +77,12 @@ def team_post_save_callback(sender, instance, **kwargs): # pylint: disable=unus
) )
class TeamAPIPagination(DefaultPagination): class TopicsPagination(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):
"""Paginate topics. """ """Paginate topics. """
page_size = TOPICS_PER_PAGE page_size = TOPICS_PER_PAGE
class MyTeamsPagination(TeamAPIPagination): class MyTeamsPagination(DefaultPagination):
"""Paginate the user's teams. """ """Paginate the user's teams. """
page_size = TEAM_MEMBERSHIPS_PER_PAGE page_size = TEAM_MEMBERSHIPS_PER_PAGE
...@@ -381,7 +356,6 @@ class TeamsListView(ExpandableFieldViewMixin, GenericAPIView): ...@@ -381,7 +356,6 @@ class TeamsListView(ExpandableFieldViewMixin, GenericAPIView):
authentication_classes = (OAuth2Authentication, SessionAuthentication) authentication_classes = (OAuth2Authentication, SessionAuthentication)
permission_classes = (permissions.IsAuthenticated,) permission_classes = (permissions.IsAuthenticated,)
serializer_class = CourseTeamSerializer serializer_class = CourseTeamSerializer
pagination_class = TeamAPIPagination
def get(self, request): def get(self, request):
"""GET /api/team/v0/teams/""" """GET /api/team/v0/teams/"""
......
...@@ -290,6 +290,7 @@ MKTG_URLS = ENV_TOKENS.get('MKTG_URLS', MKTG_URLS) ...@@ -290,6 +290,7 @@ MKTG_URLS = ENV_TOKENS.get('MKTG_URLS', MKTG_URLS)
BADGR_API_TOKEN = ENV_TOKENS.get('BADGR_API_TOKEN', BADGR_API_TOKEN) BADGR_API_TOKEN = ENV_TOKENS.get('BADGR_API_TOKEN', BADGR_API_TOKEN)
BADGR_BASE_URL = ENV_TOKENS.get('BADGR_BASE_URL', BADGR_BASE_URL) BADGR_BASE_URL = ENV_TOKENS.get('BADGR_BASE_URL', BADGR_BASE_URL)
BADGR_ISSUER_SLUG = ENV_TOKENS.get('BADGR_ISSUER_SLUG', BADGR_ISSUER_SLUG) BADGR_ISSUER_SLUG = ENV_TOKENS.get('BADGR_ISSUER_SLUG', BADGR_ISSUER_SLUG)
BADGR_TIMEOUT = ENV_TOKENS.get('BADGR_TIMEOUT', BADGR_TIMEOUT)
# git repo loading environment # git repo loading environment
GIT_REPO_DIR = ENV_TOKENS.get('GIT_REPO_DIR', '/edx/var/edxapp/course_repos') GIT_REPO_DIR = ENV_TOKENS.get('GIT_REPO_DIR', '/edx/var/edxapp/course_repos')
......
...@@ -161,6 +161,9 @@ FEATURES['ENABLE_COURSEWARE_SEARCH'] = True ...@@ -161,6 +161,9 @@ FEATURES['ENABLE_COURSEWARE_SEARCH'] = True
# Enable dashboard search for tests # Enable dashboard search for tests
FEATURES['ENABLE_DASHBOARD_SEARCH'] = True FEATURES['ENABLE_DASHBOARD_SEARCH'] = True
# Enable support for OpenBadges accomplishments
FEATURES['ENABLE_OPENBADGES'] = True
# Use MockSearchEngine as the search engine for test scenario # Use MockSearchEngine as the search engine for test scenario
SEARCH_ENGINE = "search.tests.mock_search_engine.MockSearchEngine" SEARCH_ENGINE = "search.tests.mock_search_engine.MockSearchEngine"
# Path at which to store the mock index # Path at which to store the mock index
...@@ -184,6 +187,8 @@ PROFILE_IMAGE_BACKEND = { ...@@ -184,6 +187,8 @@ PROFILE_IMAGE_BACKEND = {
FEATURES['ENABLE_CSMH_EXTENDED'] = True FEATURES['ENABLE_CSMH_EXTENDED'] = True
INSTALLED_APPS += ('coursewarehistoryextended',) INSTALLED_APPS += ('coursewarehistoryextended',)
BADGING_BACKEND = 'lms.djangoapps.badges.backends.tests.dummy_backend.DummyBackend'
##################################################################### #####################################################################
# Lastly, see if the developer has any local overrides. # Lastly, see if the developer has any local overrides.
try: try:
......
...@@ -2022,6 +2022,9 @@ INSTALLED_APPS = ( ...@@ -2022,6 +2022,9 @@ INSTALLED_APPS = (
# Learner's dashboard # Learner's dashboard
'learner_dashboard', 'learner_dashboard',
# Needed whether or not enabled, due to migrations
'badges',
) )
# Migrations which are not in the standard module "migrations" # Migrations which are not in the standard module "migrations"
...@@ -2265,12 +2268,17 @@ REGISTRATION_EMAIL_PATTERNS_ALLOWED = None ...@@ -2265,12 +2268,17 @@ REGISTRATION_EMAIL_PATTERNS_ALLOWED = None
CERT_NAME_SHORT = "Certificate" CERT_NAME_SHORT = "Certificate"
CERT_NAME_LONG = "Certificate of Achievement" CERT_NAME_LONG = "Certificate of Achievement"
#################### Badgr OpenBadges generation ####################### #################### OpenBadges Settings #######################
BADGING_BACKEND = 'badges.backends.badgr.BadgrBackend'
# Be sure to set up images for course modes using the BadgeImageConfiguration model in the certificates app. # Be sure to set up images for course modes using the BadgeImageConfiguration model in the certificates app.
BADGR_API_TOKEN = None BADGR_API_TOKEN = None
# Do not add the trailing slash here. # Do not add the trailing slash here.
BADGR_BASE_URL = "http://localhost:8005" BADGR_BASE_URL = "http://localhost:8005"
BADGR_ISSUER_SLUG = "example-issuer" BADGR_ISSUER_SLUG = "example-issuer"
# Number of seconds to wait on the badging server when contacting it before giving up.
BADGR_TIMEOUT = 10
###################### Grade Downloads ###################### ###################### Grade Downloads ######################
# These keys are used for all of our asynchronous downloadable files, including # These keys are used for all of our asynchronous downloadable files, including
...@@ -2635,6 +2643,8 @@ ACCOUNT_VISIBILITY_CONFIGURATION = { ...@@ -2635,6 +2643,8 @@ ACCOUNT_VISIBILITY_CONFIGURATION = {
'language_proficiencies', 'language_proficiencies',
'bio', 'bio',
'account_privacy', '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 # The list of account fields that are always public
...@@ -2662,6 +2672,7 @@ ACCOUNT_VISIBILITY_CONFIGURATION = { ...@@ -2662,6 +2672,7 @@ ACCOUNT_VISIBILITY_CONFIGURATION = {
"mailing_address", "mailing_address",
"requires_parental_consent", "requires_parental_consent",
"account_privacy", "account_privacy",
"accomplishments_shared",
] ]
} }
......
...@@ -13,7 +13,7 @@ ...@@ -13,7 +13,7 @@
course_id: function () { return encodeURIComponent(options.course_id); }, course_id: function () { return encodeURIComponent(options.course_id); },
fields : function () { return encodeURIComponent('display_name,path'); } 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 delete this.server_api.sort_order; // Sort order is not specified for the Bookmark API
}, },
......
...@@ -10,7 +10,7 @@ define([ ...@@ -10,7 +10,7 @@ define([
PagingCollection.prototype.initialize.call(this); PagingCollection.prototype.initialize.call(this);
this.perPage = options.perPage; 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) { if (options.text) {
this.server_api.text = options.text; this.server_api.text = options.text;
} }
......
...@@ -633,7 +633,6 @@ ...@@ -633,7 +633,6 @@
define([ define([
// Run the LMS tests // Run the LMS tests
'lms/include/js/spec/components/header/header_spec.js', '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/components/card/card_spec.js',
'lms/include/js/spec/staff_debug_actions_spec.js', 'lms/include/js/spec/staff_debug_actions_spec.js',
'lms/include/js/spec/views/notification_spec.js', 'lms/include/js/spec/views/notification_spec.js',
...@@ -668,6 +667,11 @@ ...@@ -668,6 +667,11 @@
'lms/include/js/spec/student_profile/learner_profile_factory_spec.js', 'lms/include/js/spec/student_profile/learner_profile_factory_spec.js',
'lms/include/js/spec/student_profile/learner_profile_view_spec.js', 'lms/include/js/spec/student_profile/learner_profile_view_spec.js',
'lms/include/js/spec/student_profile/learner_profile_fields_spec.js', 'lms/include/js/spec/student_profile/learner_profile_fields_spec.js',
'lms/include/js/spec/student_profile/share_modal_view_spec.js',
'lms/include/js/spec/student_profile/badge_view_spec.js',
'lms/include/js/spec/student_profile/section_two_tab_spec.js',
'lms/include/js/spec/student_profile/badge_list_view_spec.js',
'lms/include/js/spec/student_profile/badge_list_container_spec.js',
'lms/include/js/spec/verify_student/pay_and_verify_view_spec.js', 'lms/include/js/spec/verify_student/pay_and_verify_view_spec.js',
'lms/include/js/spec/verify_student/reverify_view_spec.js', 'lms/include/js/spec/verify_student/reverify_view_spec.js',
'lms/include/js/spec/verify_student/webcam_photo_view_spec.js', 'lms/include/js/spec/verify_student/webcam_photo_view_spec.js',
......
...@@ -3,8 +3,10 @@ define(['underscore'], function(_) { ...@@ -3,8 +3,10 @@ define(['underscore'], function(_) {
var USER_ACCOUNTS_API_URL = '/api/user/v0/accounts/student'; var USER_ACCOUNTS_API_URL = '/api/user/v0/accounts/student';
var USER_PREFERENCES_API_URL = '/api/user/v0/preferences/student'; var USER_PREFERENCES_API_URL = '/api/user/v0/preferences/student';
var BADGES_API_URL = '/api/badges/v1/assertions/user/student/';
var IMAGE_UPLOAD_API_URL = '/api/profile_images/v0/staff/upload'; var IMAGE_UPLOAD_API_URL = '/api/profile_images/v0/staff/upload';
var IMAGE_REMOVE_API_URL = '/api/profile_images/v0/staff/remove'; var IMAGE_REMOVE_API_URL = '/api/profile_images/v0/staff/remove';
var FIND_COURSES_URL = '/courses';
var PROFILE_IMAGE = { var PROFILE_IMAGE = {
image_url_large: '/media/profile-images/image.jpg', image_url_large: '/media/profile-images/image.jpg',
...@@ -23,7 +25,8 @@ define(['underscore'], function(_) { ...@@ -23,7 +25,8 @@ define(['underscore'], function(_) {
language: null, language: null,
bio: "About the student", bio: "About the student",
language_proficiencies: [{code: '1'}], language_proficiencies: [{code: '1'}],
profile_image: PROFILE_IMAGE profile_image: PROFILE_IMAGE,
accomplishments_shared: false
}; };
var createAccountSettingsData = function(options) { var createAccountSettingsData = function(options) {
...@@ -109,6 +112,8 @@ define(['underscore'], function(_) { ...@@ -109,6 +112,8 @@ define(['underscore'], function(_) {
return { return {
USER_ACCOUNTS_API_URL: USER_ACCOUNTS_API_URL, USER_ACCOUNTS_API_URL: USER_ACCOUNTS_API_URL,
USER_PREFERENCES_API_URL: USER_PREFERENCES_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_UPLOAD_API_URL: IMAGE_UPLOAD_API_URL,
IMAGE_REMOVE_API_URL: IMAGE_REMOVE_API_URL, IMAGE_REMOVE_API_URL: IMAGE_REMOVE_API_URL,
IMAGE_MAX_BYTES: IMAGE_MAX_BYTES, IMAGE_MAX_BYTES: IMAGE_MAX_BYTES,
......
define(['backbone', 'jquery', 'underscore', 'URI', 'common/js/spec_helpers/ajax_helpers',
'js/spec/student_profile/helpers',
'js/student_profile/views/badge_list_container',
'common/js/components/collections/paging_collection'
],
function (Backbone, $, _, URI, AjaxHelpers, LearnerProfileHelpers, BadgeListContainer, PagingCollection) {
'use strict';
describe('edx.user.BadgeListContainer', function () {
var view, requests;
var createView = function (requests, badge_list_object) {
var badgeCollection = new PagingCollection();
badgeCollection.url = '/api/badges/v1/assertions/user/staff/';
var models = [];
_.each(_.range(badge_list_object.count), function (idx) {
models.push(LearnerProfileHelpers.makeBadge(idx));
});
badge_list_object.results = models;
badgeCollection.fetch();
var request = AjaxHelpers.currentRequest(requests);
var path = new URI(request.url).path();
expect(path).toBe('/api/badges/v1/assertions/user/staff/');
AjaxHelpers.respondWithJson(requests, badge_list_object);
var badge_list_container = new BadgeListContainer({
'collection': badgeCollection
});
badge_list_container.render();
return badge_list_container;
};
afterEach(function () {
view.$el.remove();
});
it('displays all badges', function () {
requests = AjaxHelpers.requests(this);
view = createView(requests, {
count: 30,
previous: '/arbitrary/url',
num_pages: 3,
next: null,
start: 20,
current_page: 1,
results: []
});
var badges = view.$el.find('div.badge-display');
expect(badges.length).toBe(30);
});
it('displays placeholder on last page', function () {
requests = AjaxHelpers.requests(this);
view = createView(requests, {
count: 30,
previous: '/arbitrary/url',
num_pages: 3,
next: null,
start: 20,
current_page: 3,
results: []
});
var placeholder = view.$el.find('span.accomplishment-placeholder');
expect(placeholder.length).toBe(1);
});
it('does not display placeholder on first page', function () {
requests = AjaxHelpers.requests(this);
view = createView(requests, {
count: 30,
previous: '/arbitrary/url',
num_pages: 3,
next: null,
start: 0,
current_page: 1,
results: []
});
var placeholder = view.$el.find('span.accomplishment-placeholder');
expect(placeholder.length).toBe(0);
});
});
}
);
define(['backbone', 'jquery', 'underscore',
'js/spec/student_profile/helpers',
'js/student_profile/views/badge_list_view',
'common/js/components/collections/paging_collection'
],
function (Backbone, $, _, LearnerProfileHelpers, BadgeListView, PagingCollection) {
"use strict";
describe("edx.user.BadgeListView", function () {
var view;
var createView = function (badges, pages, page, hasNextPage) {
var badgeCollection = new PagingCollection();
badgeCollection.url = "/api/badges/v1/assertions/user/staff/";
var models = [];
_.each(badges, function (element) {
models.push(new Backbone.Model(element));
});
badgeCollection.models = models;
badgeCollection.length = badges.length;
badgeCollection.currentPage = page;
badgeCollection.totalPages = pages;
badgeCollection.hasNextPage = function () {
return hasNextPage;
};
var badge_list = new BadgeListView({
'collection': badgeCollection
});
return badge_list;
};
afterEach(function () {
view.$el.remove();
});
it("there is a single row if there is only one badge", function () {
view = createView([LearnerProfileHelpers.makeBadge(1)], 1, 1, false);
view.render();
var rows = view.$el.find('div.row');
expect(rows.length).toBe(1);
});
it("accomplishments placeholder is visible on a last page", function () {
view = createView([LearnerProfileHelpers.makeBadge(1)], 2, 2, false);
view.render();
var placeholder = view.$el.find('span.accomplishment-placeholder');
expect(placeholder.length).toBe(1);
});
it("accomplishments placeholder to be not visible on a first page", function () {
view = createView([LearnerProfileHelpers.makeBadge(1)], 1, 2, true);
view.render();
var placeholder = view.$el.find('span.accomplishment-placeholder');
expect(placeholder.length).toBe(0);
});
it("badges are in two columns (checked by counting rows for a known number of badges)", function () {
var badges = [];
_.each(_.range(4), function (item) {
badges.push(LearnerProfileHelpers.makeBadge(item));
});
view = createView(badges, 1, 2, true);
view.render();
var placeholder = view.$el.find('span.accomplishment-placeholder');
expect(placeholder.length).toBe(0);
var rows = view.$el.find('div.row');
expect(rows.length).toBe(2);
});
});
}
);
define(['backbone', 'jquery', 'underscore',
'js/spec/student_profile/helpers',
'js/student_profile/views/badge_view'
],
function (Backbone, $, _, LearnerProfileHelpers, BadgeView) {
"use strict";
describe("edx.user.BadgeView", function () {
var view, badge;
var createView = function (ownProfile) {
badge = LearnerProfileHelpers.makeBadge(1);
var options = {
'model': new Backbone.Model(badge),
'ownProfile': ownProfile,
'badgeMeta': {}
};
var view = new BadgeView(options);
view.render();
$('body').append(view.$el);
view.$el.show();
expect(view.$el.is(':visible')).toBe(true);
return view;
};
afterEach(function () {
view.$el.remove();
$('.badges-modal').remove();
});
it("profile of other has no share button", function () {
view = createView(false);
expect(view.context.ownProfile).toBeFalsy();
expect(view.$el.find('button.share-button').length).toBe(0);
});
it("own profile has share button", function () {
view = createView(true);
expect(view.context.ownProfile).toBeTruthy();
expect(view.$el.find('button.share-button').length).toBe(1);
});
it("click on share button calls createModal function", function () {
view = createView(true);
spyOn(view, "createModal");
view.delegateEvents();
expect(view.context.ownProfile).toBeTruthy();
var shareButton = view.$el.find('button.share-button');
expect(shareButton.length).toBe(1);
expect(view.createModal).not.toHaveBeenCalled();
shareButton.click();
expect(view.createModal).toHaveBeenCalled();
});
it("click on share button calls shows the dialog", function () {
view = createView(true);
expect(view.context.ownProfile).toBeTruthy();
var shareButton = view.$el.find('button.share-button');
expect(shareButton.length).toBe(1);
var modalElement = $('.badges-modal');
expect(modalElement.length).toBe(0);
expect(modalElement.is(":visible")).toBeFalsy();
shareButton.click();
// Note: this element should have appeared in the dom during: shareButton.click();
modalElement = $('.badges-modal');
waitsFor(function () {
return modalElement.is(":visible");
}, '', 1000);
});
var testBadgeNameIsDisplayed = function (ownProfile) {
view = createView(ownProfile);
var badgeDiv = view.$el.find(".badge-name");
expect(badgeDiv.length).toBeTruthy();
expect(badgeDiv.is(':visible')).toBe(true);
expect(_.count(badgeDiv.html(), badge.badge_class.display_name)).toBeTruthy();
};
it("test badge name is displayed for own profile", function () {
testBadgeNameIsDisplayed(true);
});
it("test badge name is displayed for other profile", function () {
testBadgeNameIsDisplayed(false);
});
var testBadgeIconIsDisplayed = function (ownProfile) {
view = createView(ownProfile);
var badgeImg = view.$el.find("img.badge");
expect(badgeImg.length).toBe(1);
expect(badgeImg.attr('src')).toEqual(badge.image_url);
};
it("test badge icon is displayed for own profile", function () {
testBadgeIconIsDisplayed(true);
});
it("test badge icon is displayed for other profile", function () {
testBadgeIconIsDisplayed(false);
});
});
}
);
define(['underscore'], function(_) { define(['underscore', 'URI', 'common/js/spec_helpers/ajax_helpers'], function(_, URI, AjaxHelpers) {
'use strict'; 'use strict';
var expectProfileElementContainsField = function(element, view) { var expectProfileElementContainsField = function(element, view) {
...@@ -93,7 +93,7 @@ define(['underscore'], function(_) { ...@@ -93,7 +93,7 @@ define(['underscore'], function(_) {
if (othersProfile) { if (othersProfile) {
expect($('.profile-private--message').text()) 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 { } else {
expect($('.profile-private--message').text()).toBe('You are currently sharing a limited profile.'); expect($('.profile-private--message').text()).toBe('You are currently sharing a limited profile.');
} }
...@@ -105,9 +105,138 @@ define(['underscore'], function(_) { ...@@ -105,9 +105,138 @@ define(['underscore'], function(_) {
expect(learnerProfileView.$('.wrapper-profile-section-two').length).toBe(0); 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 expectBadgeLoadingErrorIsRendered = function(learnerProfileView) {
var errorMessage = learnerProfileView.$el.find(".badge-set-display").text();
expect(errorMessage).toBe(
'Your request could not be completed. Reload the page and try again. If the issue persists, click the ' +
'Help tab to report the problem.'
);
};
var breakBadgeLoading = function(learnerProfileView, requests) {
var request = AjaxHelpers.currentRequest(requests);
var path = new URI(request.url).path();
expect(path).toBe('/api/badges/v1/assertions/user/student/');
AjaxHelpers.respondWithError(requests, 500);
};
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 { return {
expectLimitedProfileSectionsAndFieldsToBeRendered: expectLimitedProfileSectionsAndFieldsToBeRendered, expectLimitedProfileSectionsAndFieldsToBeRendered: expectLimitedProfileSectionsAndFieldsToBeRendered,
expectProfileSectionsAndFieldsToBeRendered: expectProfileSectionsAndFieldsToBeRendered, expectProfileSectionsAndFieldsToBeRendered: expectProfileSectionsAndFieldsToBeRendered,
expectProfileSectionsNotToBeRendered: expectProfileSectionsNotToBeRendered expectProfileSectionsNotToBeRendered: expectProfileSectionsNotToBeRendered,
expectTabbedViewToBeHidden: expectTabbedViewToBeHidden, expectTabbedViewToBeShown: expectTabbedViewToBeShown,
expectBadgesDisplayed: expectBadgesDisplayed, expectBadgesHidden: expectBadgesHidden,
expectBadgeLoadingErrorIsRendered: expectBadgeLoadingErrorIsRendered, breakBadgeLoading: breakBadgeLoading,
firstPageBadges: firstPageBadges, secondPageBadges: secondPageBadges, thirdPageBadges: thirdPageBadges,
emptyBadges: emptyBadges, expectPage: expectPage, makeBadge: makeBadge
}; };
}); });
...@@ -6,12 +6,14 @@ define(['backbone', 'jquery', 'underscore', 'common/js/spec_helpers/ajax_helpers ...@@ -6,12 +6,14 @@ define(['backbone', 'jquery', 'underscore', 'common/js/spec_helpers/ajax_helpers
'js/student_account/models/user_preferences_model', 'js/student_account/models/user_preferences_model',
'js/student_profile/views/learner_profile_fields', 'js/student_profile/views/learner_profile_fields',
'js/student_profile/views/learner_profile_view', 'js/student_profile/views/learner_profile_view',
'js/student_profile/views/badge_list_container',
'js/student_account/views/account_settings_fields', 'js/student_account/views/account_settings_fields',
'common/js/components/collections/paging_collection',
'js/views/message_banner' 'js/views/message_banner'
], ],
function (Backbone, $, _, AjaxHelpers, TemplateHelpers, Helpers, LearnerProfileHelpers, FieldViews, function (Backbone, $, _, AjaxHelpers, TemplateHelpers, Helpers, LearnerProfileHelpers, FieldViews,
UserAccountModel, AccountPreferencesModel, LearnerProfileFields, LearnerProfileView, UserAccountModel, AccountPreferencesModel, LearnerProfileFields, LearnerProfileView,
AccountSettingsFieldViews, MessageBannerView) { BadgeListContainer, AccountSettingsFieldViews, PagingCollection, MessageBannerView) {
'use strict'; 'use strict';
describe("edx.user.LearnerProfileView", function () { describe("edx.user.LearnerProfileView", function () {
...@@ -106,6 +108,15 @@ define(['backbone', 'jquery', 'underscore', 'common/js/spec_helpers/ajax_helpers ...@@ -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( return new LearnerProfileView(
{ {
el: $('.wrapper-profile'), el: $('.wrapper-profile'),
...@@ -117,7 +128,8 @@ define(['backbone', 'jquery', 'underscore', 'common/js/spec_helpers/ajax_helpers ...@@ -117,7 +128,8 @@ define(['backbone', 'jquery', 'underscore', 'common/js/spec_helpers/ajax_helpers
usernameFieldView: usernameFieldView, usernameFieldView: usernameFieldView,
profileImageFieldView: profileImageFieldView, profileImageFieldView: profileImageFieldView,
sectionOneFieldViews: sectionOneFieldViews, sectionOneFieldViews: sectionOneFieldViews,
sectionTwoFieldViews: sectionTwoFieldViews sectionTwoFieldViews: sectionTwoFieldViews,
badgeListContainer: badgeListContainer
}); });
}; };
...@@ -125,6 +137,10 @@ define(['backbone', 'jquery', 'underscore', 'common/js/spec_helpers/ajax_helpers ...@@ -125,6 +137,10 @@ define(['backbone', 'jquery', 'underscore', 'common/js/spec_helpers/ajax_helpers
loadFixtures('js/fixtures/student_profile/student_profile.html'); loadFixtures('js/fixtures/student_profile/student_profile.html');
}); });
afterEach(function () {
Backbone.history.stop();
});
it("shows loading error correctly", function() { it("shows loading error correctly", function() {
var learnerProfileView = createLearnerProfileView(false, 'all_users'); var learnerProfileView = createLearnerProfileView(false, 'all_users');
...@@ -189,5 +205,16 @@ define(['backbone', 'jquery', 'underscore', 'common/js/spec_helpers/ajax_helpers ...@@ -189,5 +205,16 @@ define(['backbone', 'jquery', 'underscore', 'common/js/spec_helpers/ajax_helpers
Helpers.expectLoadingErrorIsVisible(learnerProfileView, false); Helpers.expectLoadingErrorIsVisible(learnerProfileView, false);
LearnerProfileHelpers.expectLimitedProfileSectionsAndFieldsToBeRendered(learnerProfileView, true); LearnerProfileHelpers.expectLimitedProfileSectionsAndFieldsToBeRendered(learnerProfileView, true);
}); });
it("renders an error if the badges can't be fetched", function () {
var learnerProfileView = createLearnerProfileView(false, 'all_users', true);
learnerProfileView.options.accountSettingsModel.set({'accomplishments_shared': true});
var requests = AjaxHelpers.requests(this);
learnerProfileView.render();
LearnerProfileHelpers.breakBadgeLoading(learnerProfileView, requests);
LearnerProfileHelpers.expectBadgeLoadingErrorIsRendered(learnerProfileView);
});
}); });
}); });
define(['backbone', 'jquery', 'underscore',
'js/spec/student_account/helpers',
'js/student_profile/views/section_two_tab',
'js/views/fields',
'js/student_account/models/user_account_model'
],
function (Backbone, $, _, Helpers, SectionTwoTabView, FieldViews, UserAccountModel) {
"use strict";
describe("edx.user.SectionTwoTab", function () {
var createSectionTwoView = function (ownProfile, profileIsPublic) {
var accountSettingsModel = new UserAccountModel();
accountSettingsModel.set(Helpers.createAccountSettingsData());
accountSettingsModel.set({'profile_is_public': profileIsPublic});
accountSettingsModel.set({'profile_image': Helpers.PROFILE_IMAGE});
var editable = ownProfile ? 'toggle' : 'never';
var sectionTwoFieldViews = [
new FieldViews.TextareaFieldView({
model: accountSettingsModel,
editable: editable,
showMessages: false,
title: 'About me',
placeholderValue: "Tell other edX learners a little about yourself: where you live, " +
"what your interests are, why you're taking courses on edX, or what you hope to learn.",
valueAttribute: "bio",
helpMessage: '',
messagePosition: 'header'
})
];
return new SectionTwoTabView({
viewList: sectionTwoFieldViews,
showFullProfile: function(){
return profileIsPublic;
},
ownProfile: ownProfile
});
};
it("full profile displayed for public profile", function () {
var view = createSectionTwoView(false, true);
view.render();
var bio = view.$el.find('.u-field-bio');
expect(bio.length).toBe(1);
});
it("profile field parts are actually rendered for public profile", function () {
var view = createSectionTwoView(false, true);
_.each(view.options.viewList, function (fieldView) {
spyOn(fieldView, "render").andCallThrough();
});
view.render();
_.each(view.options.viewList, function (fieldView) {
expect(fieldView.render).toHaveBeenCalled();
});
});
var testPrivateProfile = function (ownProfile, msg_string) {
var view = createSectionTwoView(ownProfile, false);
view.render();
var bio = view.$el.find('.u-field-bio');
expect(bio.length).toBe(0);
var msg = view.$el.find('span.profile-private--message');
expect(msg.length).toBe(1);
expect(_.count(msg.html(), msg_string)).toBeTruthy();
};
it("no profile when profile is private for other people", function () {
testPrivateProfile(false, "This learner is currently sharing a limited profile");
});
it("no profile when profile is private for the user herself", function () {
testPrivateProfile(true, "You are currently sharing a limited profile");
});
var testProfilePrivatePartsDoNotRender = function (ownProfile) {
var view = createSectionTwoView(ownProfile, false);
_.each(view.options.viewList, function (fieldView) {
spyOn(fieldView, "render");
});
view.render();
_.each(view.options.viewList, function (fieldView) {
expect(fieldView.render).not.toHaveBeenCalled();
});
};
it("profile field parts are not rendered for private profile for owner", function () {
testProfilePrivatePartsDoNotRender(true);
});
it("profile field parts are not rendered for private profile for other people", function () {
testProfilePrivatePartsDoNotRender(false);
});
it("does not allow fields to be edited when visiting a profile for other people", function () {
var view = createSectionTwoView(false, true);
var bio = view.options.viewList[0];
expect(bio.editable).toBe("never");
});
it("allows fields to be edited when visiting one's own profile", function () {
var view = createSectionTwoView(true, true);
var bio = view.options.viewList[0];
expect(bio.editable).toBe("toggle");
});
});
}
);
define(['backbone', 'jquery', 'underscore', 'moment',
'js/spec/student_account/helpers',
'js/spec/student_profile/helpers',
'js/student_profile/views/share_modal_view',
'jquery.simulate'
],
function (Backbone, $, _, Moment, Helpers, LearnerProfileHelpers, ShareModalView) {
"use strict";
describe("edx.user.ShareModalView", function () {
var keys = $.simulate.keyCode;
var view;
var createModalView = function () {
var badge = LearnerProfileHelpers.makeBadge(1);
var context = _.extend(badge, {
'created': new Moment(badge.created),
'ownProfile': true,
'badgeMeta': {}
});
return new ShareModalView({
model: new Backbone.Model(context),
shareButton: $("<button/>")
});
};
beforeEach(function () {
view = createModalView();
// Attach view to document, otherwise click won't work
view.render();
$('body').append(view.$el);
view.$el.show();
expect(view.$el.is(':visible')).toBe(true);
});
afterEach(function () {
view.$el.remove();
});
it("modal view closes on escape", function () {
spyOn(view, "close");
view.delegateEvents();
expect(view.close).not.toHaveBeenCalled();
$(view.$el).simulate("keydown", {keyCode: keys.ESCAPE});
expect(view.close).toHaveBeenCalled();
});
it("modal view closes click on close", function () {
spyOn(view, "close");
view.delegateEvents();
var $closeButton = view.$el.find("button.close");
expect($closeButton.length).toBe(1);
expect(view.close).not.toHaveBeenCalled();
$closeButton.trigger('click');
expect(view.close).toHaveBeenCalled();
});
});
}
);
...@@ -23,6 +23,7 @@ ...@@ -23,6 +23,7 @@
language_proficiencies: [], language_proficiencies: [],
requires_parental_consent: true, requires_parental_consent: true,
profile_image: null, profile_image: null,
accomplishments_shared: false,
default_public_account_fields: [] default_public_account_fields: []
}, },
......
;(function (define) {
'use strict';
define(['backbone'], function(Backbone) {
var BadgesModel = Backbone.Model.extend({});
return BadgesModel;
});
}).call(this, define || RequireJS.define);
;(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;
this.listView.badgeMeta = options.badgeMeta;
this.listView.ownProfile = options.ownProfile;
},
type: 'badge',
itemViewClass: BadgeView,
listViewClass: BadgeListView,
viewTemplate: BadgeListTemplate,
isZeroIndexed: true,
paginationLabel: gettext("Accomplishments Pagination")
});
return BadgeListContainer;
});
}).call(this, define || RequireJS.define);
;(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,
badgeMeta: this.badgeMeta,
ownProfile: this.ownProfile
}).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);
;(function (define, undefined) {
'use strict';
define(['gettext', 'jquery', 'underscore', 'backbone', 'moment',
'text!templates/student_profile/badge.underscore',
'js/student_profile/views/share_modal_view'],
function (gettext, $, _, Backbone, Moment, badgeTemplate, ShareModalView) {
var BadgeView = Backbone.View.extend({
initialize: function(options) {
this.context = _.extend(this.options.model.toJSON(), {
'created': new Moment(this.options.model.toJSON().created),
'ownProfile': options.ownProfile,
'badgeMeta': options.badgeMeta
});
},
attributes: {
'class': 'badge-display'
},
template: _.template(badgeTemplate),
events: {
'click .share-button': 'createModal'
},
createModal: function() {
var modal = new ShareModalView({
model: new Backbone.Model(this.context),
shareButton: this.shareButton
});
modal.$el.hide();
modal.render();
$('body').append(modal.$el);
modal.$el.fadeIn('short', 'swing', _.bind(modal.ready, modal));
},
render: function () {
this.$el.html(this.template(this.context));
this.shareButton = this.$el.find('.share-button');
return this;
}
});
return BadgeView;
});
}).call(this, define || RequireJS.define);
...@@ -7,11 +7,15 @@ ...@@ -7,11 +7,15 @@
'js/views/fields', 'js/views/fields',
'js/student_profile/views/learner_profile_fields', 'js/student_profile/views/learner_profile_fields',
'js/student_profile/views/learner_profile_view', '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/student_account/views/account_settings_fields',
'js/views/message_banner', 'js/views/message_banner',
'string_utils' 'string_utils'
], function (gettext, $, _, Backbone, Logger, AccountSettingsModel, AccountPreferencesModel, FieldsView, ], function (gettext, $, _, Backbone, Logger, AccountSettingsModel, AccountPreferencesModel, FieldsView,
LearnerProfileFieldsView, LearnerProfileView, AccountSettingsFieldViews, MessageBannerView) { LearnerProfileFieldsView, LearnerProfileView, BadgeModel, PagingCollection, BadgeListContainer,
AccountSettingsFieldViews, MessageBannerView) {
return function (options) { return function (options) {
...@@ -121,6 +125,21 @@ ...@@ -121,6 +125,21 @@
}) })
]; ];
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,
'ownProfile': options.own_profile,
'badgeMeta': {
'badges_logo': options.badges_logo,
'backpack_ui_img': options.backpack_ui_img,
'badges_icon': options.badges_icon
}
});
var learnerProfileView = new LearnerProfileView({ var learnerProfileView = new LearnerProfileView({
el: learnerProfileElement, el: learnerProfileElement,
ownProfile: options.own_profile, ownProfile: options.own_profile,
...@@ -131,7 +150,8 @@ ...@@ -131,7 +150,8 @@
profileImageFieldView: profileImageFieldView, profileImageFieldView: profileImageFieldView,
usernameFieldView: usernameFieldView, usernameFieldView: usernameFieldView,
sectionOneFieldViews: sectionOneFieldViews, sectionOneFieldViews: sectionOneFieldViews,
sectionTwoFieldViews: sectionTwoFieldViews sectionTwoFieldViews: sectionTwoFieldViews,
badgeListContainer: badgeListContainer
}); });
var getProfileVisibility = function() { var getProfileVisibility = function() {
...@@ -164,7 +184,8 @@ ...@@ -164,7 +184,8 @@
return { return {
accountSettingsModel: accountSettingsModel, accountSettingsModel: accountSettingsModel,
accountPreferencesModel: accountPreferencesModel, accountPreferencesModel: accountPreferencesModel,
learnerProfileView: learnerProfileView learnerProfileView: learnerProfileView,
badgeListContainer: badgeListContainer
}; };
}; };
}); });
......
;(function (define, undefined) { ;(function (define, undefined) {
'use strict'; 'use strict';
define([ define([
'gettext', 'jquery', 'underscore', 'backbone', 'text!templates/student_profile/learner_profile.underscore'], 'gettext', 'jquery', 'underscore', 'backbone',
function (gettext, $, _, Backbone, learnerProfileTemplate) { '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({ var LearnerProfileView = Backbone.View.extend({
initialize: function () { initialize: function () {
_.bindAll(this, 'showFullProfile', 'render', 'renderFields', 'showLoadingError'); _.bindAll(this, 'showFullProfile', 'render', 'renderFields', 'showLoadingError');
this.listenTo(this.options.preferencesModel, "change:" + 'account_privacy', this.render); 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 () { showFullProfile: function () {
var isAboveMinimumAge = this.options.accountSettingsModel.isAboveMinimumAge(); var isAboveMinimumAge = this.options.accountSettingsModel.isAboveMinimumAge();
...@@ -20,13 +31,73 @@ ...@@ -20,13 +31,73 @@
} }
}, },
setActiveTab: function(tab) {
// This tab may not actually exist.
if (this.tabbedView.getTabMeta(tab).tab) {
this.tabbedView.setActiveTab(tab);
}
},
render: function () { render: function () {
this.$el.html(_.template(learnerProfileTemplate)({ var self = this;
username: this.options.accountSettingsModel.get('username'),
ownProfile: this.options.ownProfile, this.sectionTwoView = new SectionTwoTab({
showFullProfile: this.showFullProfile() 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(); 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();
}).error(function () {
self.options.badgeListContainer.renderError();
});
}
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; return this;
}, },
...@@ -54,9 +125,6 @@ ...@@ -54,9 +125,6 @@
view.$('.profile-section-one-fields').append(fieldView.render().el); view.$('.profile-section-one-fields').append(fieldView.render().el);
}); });
_.each(this.options.sectionTwoFieldViews, function (fieldView) {
view.$('.profile-section-two-fields').append(fieldView.render().el);
});
} }
}, },
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment