Commit a04a635e by Jonathan Piacenti

Add accomplishments to user profile

parent a2104634
...@@ -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');
......
...@@ -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,10 @@ define(['sinon', 'underscore', 'URI'], function(sinon, _, URI) { ...@@ -72,6 +72,10 @@ 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 contents if this call may not be germane to the current test.
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>
""" """
Serializers for Badges Serializers for badging
""" """
from rest_framework import serializers from rest_framework import serializers
...@@ -25,4 +25,4 @@ class BadgeAssertionSerializer(serializers.ModelSerializer): ...@@ -25,4 +25,4 @@ class BadgeAssertionSerializer(serializers.ModelSerializer):
class Meta(object): class Meta(object):
model = BadgeAssertion model = BadgeAssertion
fields = ('badge_class', 'image_url', 'assertion_url') fields = ('badge_class', 'image_url', 'assertion_url', 'created')
...@@ -6,8 +6,12 @@ from opaque_keys.edx.keys import CourseKey ...@@ -6,8 +6,12 @@ from opaque_keys.edx.keys import CourseKey
from rest_framework import generics from rest_framework import generics
from rest_framework.exceptions import APIException from rest_framework.exceptions import APIException
from openedx.core.djangoapps.user_api.permissions import is_field_shared_factory
from openedx.core.lib.api.authentication import (
OAuth2AuthenticationAllowInactiveUser,
SessionAuthenticationAllowInactiveUser
)
from badges.models import BadgeAssertion from badges.models import BadgeAssertion
from openedx.core.lib.api.view_utils import view_auth_classes
from .serializers import BadgeAssertionSerializer from .serializers import BadgeAssertionSerializer
from xmodule_django.models import CourseKeyField from xmodule_django.models import CourseKeyField
...@@ -20,7 +24,6 @@ class CourseKeyError(APIException): ...@@ -20,7 +24,6 @@ class CourseKeyError(APIException):
default_detail = "The course key provided could not be parsed." default_detail = "The course key provided could not be parsed."
@view_auth_classes(is_user=True)
class UserBadgeAssertions(generics.ListAPIView): class UserBadgeAssertions(generics.ListAPIView):
""" """
** Use cases ** ** Use cases **
...@@ -89,6 +92,17 @@ class UserBadgeAssertions(generics.ListAPIView): ...@@ -89,6 +92,17 @@ class UserBadgeAssertions(generics.ListAPIView):
} }
""" """
serializer_class = BadgeAssertionSerializer 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): def get_queryset(self):
""" """
......
...@@ -5,6 +5,8 @@ from django.db import migrations, models ...@@ -5,6 +5,8 @@ from django.db import migrations, models
import jsonfield.fields import jsonfield.fields
import badges.models import badges.models
from django.conf import settings from django.conf import settings
import django.utils.timezone
from model_utils import fields
import xmodule_django.models import xmodule_django.models
...@@ -23,14 +25,16 @@ class Migration(migrations.Migration): ...@@ -23,14 +25,16 @@ class Migration(migrations.Migration):
('backend', models.CharField(max_length=50)), ('backend', models.CharField(max_length=50)),
('image_url', models.URLField()), ('image_url', models.URLField()),
('assertion_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( migrations.CreateModel(
name='BadgeClass', name='BadgeClass',
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)),
('slug', models.SlugField(max_length=255)), ('slug', models.SlugField(max_length=255, validators=[badges.models.validate_lowercase])),
('issuing_component', models.SlugField(default=b'', blank=True)), ('issuing_component', models.SlugField(default=b'', blank=True, validators=[badges.models.validate_lowercase])),
('display_name', models.CharField(max_length=255)), ('display_name', models.CharField(max_length=255)),
('course_id', xmodule_django.models.CourseKeyField(default=None, max_length=255, blank=True)), ('course_id', xmodule_django.models.CourseKeyField(default=None, max_length=255, blank=True)),
('description', models.TextField()), ('description', models.TextField()),
......
...@@ -2,8 +2,10 @@ ...@@ -2,8 +2,10 @@
from __future__ import unicode_literals from __future__ import unicode_literals
import json import json
from datetime import datetime
import os import os
import time
from django.db import migrations, models from django.db import migrations, models
...@@ -47,14 +49,20 @@ def forwards(apps, schema_editor): ...@@ -47,14 +49,20 @@ def forwards(apps, schema_editor):
data = badge.data data = badge.data
else: else:
data = json.dumps(badge.data) data = json.dumps(badge.data)
BadgeAssertion( assertion = BadgeAssertion(
user_id=badge.user_id, user_id=badge.user_id,
badge_class=classes[(badge.course_id, badge.mode)], badge_class=classes[(badge.course_id, badge.mode)],
data=data, data=data,
backend='BadgrBackend', backend='BadgrBackend',
image_url=badge.data['image'], image_url=badge.data['image'],
assertion_url=badge.data['json']['id'], assertion_url=badge.data['json']['id'],
).save() )
assertion.save()
# Would be overwritten by the first save.
assertion.created = datetime.fromtimestamp(
time.mktime(time.strptime(badge.data['created_at'], "%Y-%m-%dT%H:%M:%S"))
)
assertion.save()
for configuration in BadgeImageConfiguration.objects.all(): for configuration in BadgeImageConfiguration.objects.all():
file_content = ContentFile(configuration.icon.read()) file_content = ContentFile(configuration.icon.read())
......
...@@ -20,9 +20,9 @@ class Migration(migrations.Migration): ...@@ -20,9 +20,9 @@ class Migration(migrations.Migration):
('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)),
('change_date', models.DateTimeField(auto_now_add=True, verbose_name='Change date')), ('change_date', models.DateTimeField(auto_now_add=True, verbose_name='Change date')),
('enabled', models.BooleanField(default=False, verbose_name='Enabled')), ('enabled', models.BooleanField(default=False, verbose_name='Enabled')),
('courses_completed', models.TextField(default=b'', help_text="On each line, put the number of completed courses to award a badge for, a comma, and the slug of a badge class you have created with the issuing component 'edx__course'. For example: 3,course-v1:edx/Demo/DemoX", blank=True)), ('courses_completed', models.TextField(default=b'', help_text="On each line, put the number of completed courses to award a badge for, a comma, and the slug of a badge class you have created with the issuing component 'edx__course'. For example: 3,enrolled_3_courses", blank=True)),
('courses_enrolled', models.TextField(default=b'', help_text="On each line, put the number of enrolled courses to award a badge for, a comma, and the slug of a badge class you have created with the issuing component 'edx__course'. For example: 3,course-v1:edx/Demo/DemoX", blank=True)), ('courses_enrolled', models.TextField(default=b'', help_text="On each line, put the number of enrolled courses to award a badge for, a comma, and the slug of a badge class you have created with the issuing component 'edx__course'. For example: 3,enrolled_3_courses", blank=True)),
('course_groups', models.TextField(default=b'', help_text="On each line, put the slug of a badge class you have created with the issuing component 'edx__course' to award, a comma, and a comma separated list of course keys that the user will need to complete to get this badge. For example: slug_for_compsci_courses_group_badge,course-v1:CompSci+Course+First,course-v1:CompsSci+Course+Second", blank=True)), ('course_groups', models.TextField(default=b'', help_text="Each line is a comma-separated list. The first item in each line is the slug of a badge class to award, with an issuing component of 'edx__course'. The remaining items in each line are the course keys the user will need to complete to be awarded the badge. For example: slug_for_compsci_courses_group_badge,course-v1:CompSci+Course+First,course-v1:CompsSci+Course+Second", blank=True)),
('changed_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, editable=False, to=settings.AUTH_USER_MODEL, null=True, verbose_name='Changed by')), ('changed_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, editable=False, to=settings.AUTH_USER_MODEL, null=True, verbose_name='Changed by')),
], ],
), ),
......
...@@ -9,6 +9,7 @@ from django.core.exceptions import ValidationError ...@@ -9,6 +9,7 @@ from django.core.exceptions import ValidationError
from django.db import models from django.db import models
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from lazy import lazy from lazy import lazy
from model_utils.models import TimeStampedModel
from opaque_keys import InvalidKeyError from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import CourseKey from opaque_keys.edx.keys import CourseKey
...@@ -122,7 +123,7 @@ class BadgeClass(models.Model): ...@@ -122,7 +123,7 @@ class BadgeClass(models.Model):
verbose_name_plural = "Badge Classes" verbose_name_plural = "Badge Classes"
class BadgeAssertion(models.Model): class BadgeAssertion(TimeStampedModel):
""" """
Tracks badges on our side of the badge baking transaction Tracks badges on our side of the badge baking transaction
""" """
...@@ -152,6 +153,10 @@ class BadgeAssertion(models.Model): ...@@ -152,6 +153,10 @@ class BadgeAssertion(models.Model):
app_label = "badges" app_label = "badges"
# Abstract model doesn't index this, so we have to.
BadgeAssertion._meta.get_field('created').db_index = True # pylint: disable=protected-access
class CourseCompleteImageConfiguration(models.Model): class CourseCompleteImageConfiguration(models.Model):
""" """
Contains the icon configuration for badges for a specific course mode. Contains the icon configuration for badges for a specific course mode.
...@@ -216,7 +221,7 @@ class CourseEventBadgesConfiguration(ConfigurationModel): ...@@ -216,7 +221,7 @@ class CourseEventBadgesConfiguration(ConfigurationModel):
help_text=_( help_text=_(
u"On each line, put the number of completed courses to award a badge for, a comma, and the slug of a " u"On each line, put the number of completed courses to award a badge for, a comma, and the slug of a "
u"badge class you have created with the issuing component 'edx__course'. " u"badge class you have created with the issuing component 'edx__course'. "
u"For example: 3,course-v1:edx/Demo/DemoX" u"For example: 3,enrolled_3_courses"
) )
) )
courses_enrolled = models.TextField( courses_enrolled = models.TextField(
...@@ -224,7 +229,7 @@ class CourseEventBadgesConfiguration(ConfigurationModel): ...@@ -224,7 +229,7 @@ class CourseEventBadgesConfiguration(ConfigurationModel):
help_text=_( help_text=_(
u"On each line, put the number of enrolled courses to award a badge for, a comma, and the slug of a " u"On each line, put the number of enrolled courses to award a badge for, a comma, and the slug of a "
u"badge class you have created with the issuing component 'edx__course'. " u"badge class you have created with the issuing component 'edx__course'. "
u"For example: 3,course-v1:edx/Demo/DemoX" u"For example: 3,enrolled_3_courses"
) )
) )
course_groups = models.TextField( course_groups = models.TextField(
...@@ -232,8 +237,8 @@ class CourseEventBadgesConfiguration(ConfigurationModel): ...@@ -232,8 +237,8 @@ class CourseEventBadgesConfiguration(ConfigurationModel):
help_text=_( help_text=_(
u"Each line is a comma-separated list. The first item in each line is the slug of a badge class to award, " u"Each line is a comma-separated list. The first item in each line is the slug of a badge class to award, "
u"with an issuing component of 'edx__course'. The remaining items in each line are the course keys the " u"with an issuing component of 'edx__course'. The remaining items in each line are the course keys the "
u"user will need to complete to get the badge. For example: slug_for_compsci_courses_group_badge,course-v1" u"user will need to complete to be awarded the badge. For example: "
u":CompSci+Course+First,course-v1:CompsSci+Course+Second" u"slug_for_compsci_courses_group_badge,course-v1:CompSci+Course+First,course-v1:CompsSci+Course+Second"
) )
) )
......
""" 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 edxmako.shortcuts import render_to_response from edxmako.shortcuts import render_to_response, marketing_link
from microsite_configuration import microsite
from openedx.core.djangoapps.user_api.accounts.api import get_account_settings from openedx.core.djangoapps.user_api.accounts.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 +85,14 @@ def learner_profile_context(request, profile_username, user_is_staff): ...@@ -87,9 +85,14 @@ def learner_profile_context(request, profile_username, user_is_staff):
'has_preferences_access': (logged_in_user.username == profile_username or user_is_staff), '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,
'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 settings.FEATURES.get("ENABLE_OPENBADGES"):
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/"""
......
...@@ -2644,6 +2644,8 @@ ACCOUNT_VISIBILITY_CONFIGURATION = { ...@@ -2644,6 +2644,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
...@@ -2671,6 +2673,7 @@ ACCOUNT_VISIBILITY_CONFIGURATION = { ...@@ -2671,6 +2673,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',
......
...@@ -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/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,
......
...@@ -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,122 @@ define(['underscore'], function(_) { ...@@ -105,9 +105,122 @@ 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 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,
firstPageBadges: firstPageBadges, secondPageBadges: secondPageBadges, thirdPageBadges: thirdPageBadges,
emptyBadges: emptyBadges, expectPage: expectPage
}; };
}); });
...@@ -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,6 @@ define(['backbone', 'jquery', 'underscore', 'common/js/spec_helpers/ajax_helpers ...@@ -189,5 +205,6 @@ 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);
}); });
}); });
}); });
...@@ -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;
},
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}).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'],
function (gettext, $, _, Backbone, Moment, badgeTemplate) {
var BadgeView = Backbone.View.extend({
attributes: {
'class': 'badge-display'
},
template: _.template(badgeTemplate),
render: function () {
var context = _.extend(this.options.model.toJSON(), {
'created': new Moment(this.options.model.toJSON().created)
});
this.$el.html(this.template(context));
return this;
}
});
return BadgeView;
});
}).call(this, define || RequireJS.define);
...@@ -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,15 @@ ...@@ -121,6 +125,15 @@
}) })
]; ];
var badgeCollection = new PagingCollection();
badgeCollection.url = options.badges_api_url;
var badgeListContainer = new BadgeListContainer({
'attributes': {'class': 'badge-set-display'},
'collection': badgeCollection,
'find_courses_url': options.find_courses_url
});
var learnerProfileView = new LearnerProfileView({ var learnerProfileView = new LearnerProfileView({
el: learnerProfileElement, el: learnerProfileElement,
ownProfile: options.own_profile, ownProfile: options.own_profile,
...@@ -131,7 +144,8 @@ ...@@ -131,7 +144,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 +178,8 @@ ...@@ -164,7 +178,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();
if (this.options.ownProfile) { if (this.options.ownProfile) {
...@@ -20,13 +31,71 @@ ...@@ -20,13 +31,71 @@
} }
}, },
setActiveTab: function(tab) {
// This tab may not actually exist.
if (this.tabbedView.getTabMeta(tab).tab) {
this.tabbedView.setActiveTab(tab);
}
},
render: function () { 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();
});
}
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 +123,6 @@ ...@@ -54,9 +123,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);
});
} }
}, },
......
;(function (define, undefined) {
'use strict';
define([
'gettext', 'jquery', 'underscore', 'backbone', 'text!templates/student_profile/section_two.underscore'],
function (gettext, $, _, Backbone, sectionTwoTemplate) {
var SectionTwoTab = Backbone.View.extend({
attributes: {
'class': 'wrapper-profile-section-two'
},
template: _.template(sectionTwoTemplate),
render: function () {
var self = this;
var showFullProfile = this.options.showFullProfile();
this.$el.html(this.template({
ownProfile: self.options.ownProfile,
showFullProfile: showFullProfile
}));
if (showFullProfile) {
_.each(this.options.viewList, function (fieldView) {
self.$el.find('.field-container').append(fieldView.render().el);
});
}
return this;
}
});
return SectionTwoTab;
});
}).call(this, define || RequireJS.define);
...@@ -107,7 +107,6 @@ fixture_paths: ...@@ -107,7 +107,6 @@ fixture_paths:
- templates/verify_student - templates/verify_student
- templates/file-upload.underscore - templates/file-upload.underscore
- templates/components/header - templates/components/header
- templates/components/tabbed
- templates/components/card - templates/components/card
- templates/financial-assistance/ - templates/financial-assistance/
- js/fixtures/edxnotes - js/fixtures/edxnotes
......
...@@ -15,6 +15,7 @@ ...@@ -15,6 +15,7 @@
// base - elements // base - elements
@import 'elements/typography'; @import 'elements/typography';
@import 'elements/controls'; @import 'elements/controls';
@import 'elements/navigation';
@import 'elements/pagination'; @import 'elements/pagination';
@import 'elements/creative-commons'; @import 'elements/creative-commons';
@import 'elements/program-card'; @import 'elements/program-card';
......
...@@ -115,3 +115,39 @@ ...@@ -115,3 +115,39 @@
@include right($baseline*2.50); @include right($baseline*2.50);
} }
} }
%button-reset {
box-shadow: none;
border: none;
border-radius: 0;
background: transparent none;
font-family: inherit;
font-size: inherit;
font-weight: inherit;
}
%page-content-nav {
margin-bottom: $baseline;
border-bottom: 3px solid $gray-l5;
.nav-item {
@extend %button-reset;
display: inline-block;
margin-bottom: -3px; // to match the border
border-bottom: 3px solid $gray-l5;
padding: ($baseline*.75);
color: $gray-d2;
&.is-active {
border-bottom: 3px solid $gray-d2;
color: $gray-d2;
}
// STATE: hover and focus
&:hover,
&:focus {
border-bottom: 3px solid $link-color;
color: $link-color;
}
}
}
...@@ -231,6 +231,7 @@ ...@@ -231,6 +231,7 @@
} }
.wrapper-profile-section-two { .wrapper-profile-section-two {
padding-top: 1em;
width: flex-grid(8, 12); width: flex-grid(8, 12);
} }
...@@ -304,4 +305,71 @@ ...@@ -304,4 +305,71 @@
} }
} }
} }
.badge-paging-header {
padding-top: $baseline;
}
.page-content-nav {
@extend %page-content-nav;
}
.badge-set-display {
@extend .container;
padding: 0 0;
.badge-display {
width: 50%;
display: inline-block;
vertical-align: top;
padding: 2em 0;
.badge-image-container {
padding-right: $baseline;
margin-left: 1em;
width: 20%;
vertical-align: top;
display: inline-block;
img.badge {
width: 100%;
}
.accomplishment-placeholder {
border: 4px dotted $gray-l4;
border-radius: 50%;
display: block;
width: 100%;
padding-bottom: 100%;
}
}
.badge-details {
@extend %t-copy-sub1;
@extend %t-regular;
max-width: 70%;
display: inline-block;
color: $gray-d1;
.badge-name {
@extend %t-strong;
@extend %t-copy-base;
color: $gray-d3;
}
.badge-description {
padding-bottom: $baseline;
line-height: 1.5em;
}
.badge-date-stamp{
@extend %t-copy-sub1;
}
.find-button-container {
border: 1px solid $blue-l1;
padding: ($baseline / 2) $baseline ($baseline / 2) $baseline;
display: inline-block;
border-radius: 5px;
font-weight: bold;
color: $blue-s3;
}
}
}
.badge-placeholder {
background-color: $gray-l7;
box-shadow: inset 0 0 4px 0 $gray-l4;
}
}
} }
...@@ -19,17 +19,6 @@ ...@@ -19,17 +19,6 @@
color: inherit; color: inherit;
} }
%button-reset {
box-shadow: none;
border: none;
border-radius: 0;
background-image: none;
background-color: transparent;
font-family: inherit;
font-size: inherit;
font-weight: inherit;
}
// layout // layout
.page-header { .page-header {
@include clearfix(); @include clearfix();
...@@ -147,29 +136,7 @@ ...@@ -147,29 +136,7 @@
} }
.page-content-nav { .page-content-nav {
margin-bottom: $baseline; @extend %page-content-nav;
border-bottom: 3px solid $gray-l5;
.nav-item {
@extend %button-reset;
display: inline-block;
margin-bottom: -3px; // to match the border
border-bottom: 3px solid $gray-l5;
padding: ($baseline*.75);
color: $gray-d2;
&.is-active {
border-bottom: 3px solid $gray-d2;
color: $gray-d2;
}
// STATE: hover and focus
&:hover,
&:focus {
border-bottom: 3px solid $link-color;
color: $link-color;
}
}
} }
.intro { .intro {
......
<div class="badge-image-container">
<img class="badge" src="<%- image_url %>" alt=""/>
</div>
<div class="badge-details">
<div class="badge-name"><%- badge_class.display_name %></div>
<p class="badge-description"><%- badge_class.description %></p>
<div class="badge-date-stamp">
<%-
interpolate(
// Translators: Date stamp for earned badges. Example: Earned December 3, 2015.
gettext('Earned %(created)s.'),
{created: created.format('LL')},
true
)
%></div>
</div>
<div class="sr-is-focusable sr-<%= type %>-view" tabindex="-1"></div>
<div class="<%= type %>-paging-header"></div>
<ul class="<%= type %>-list cards-list"></ul>
<div class="<%= type %>-paging-footer"></div>
<div class="badge-display badge-placeholder">
<div class="badge-image-container">
<span class="accomplishment-placeholder" aria-hidden="true">
</div>
<div class="badge-details">
<div class="badge-name"><%- gettext("What's Your Next Accomplishment?") %></div>
<p class="badge-description"><%- gettext('Start working toward your next learning goal.') %></p>
<a class="find-course" href="<%= find_courses_url %>"><span class="find-button-container"><%- gettext('Find a course') %></span></a>
</div>
</div>
<div class="profile <%- ownProfile ? 'profile-self' : 'profile-other' %>"> <div class="profile <%- ownProfile ? 'profile-self' : 'profile-other' %>">
<div class="wrapper-profile-field-account-privacy"></div> <div class="wrapper-profile-field-account-privacy"></div>
<div class="wrapper-profile-sections account-settings-container"> <div class="wrapper-profile-sections account-settings-container">
<div class="wrapper-profile-section-one"> <div class="wrapper-profile-section-one">
<div class="profile-image-field"> <div class="profile-image-field">
</div> </div>
<div class="profile-section-one-fields"> <div class="profile-section-one-fields">
</div> </div>
</div> </div>
<div class="ui-loading-error is-hidden"> <div class="ui-loading-error is-hidden">
<i class="fa fa-exclamation-triangle message-error" aria-hidden=true></i> <i class="fa fa-exclamation-triangle message-error" aria-hidden=true></i>
<span class="copy"><%- gettext("An error occurred. Please reload the page.") %></span> <span class="copy"><%- gettext("An error occurred. Try loading the page again.") %></span>
</div>
<div class="wrapper-profile-section-two">
<div class="profile-section-two-fields">
<% if (!showFullProfile) { %>
<% if(ownProfile) { %>
<span class="profile-private--message" tabindex="0"><%- gettext("You are currently sharing a limited profile.") %></span>
<% } else { %>
<span class="profile-private--message" tabindex="0"><%- gettext("This edX learner is currently sharing a limited profile.") %></span>
<% } %>
<% } %>
</div>
</div> </div>
</div> </div>
</div> </div>
<div class="profile-section-two-fields">
<div class="field-container"></div>
<% if (!showFullProfile) { %>
<% if(ownProfile) { %>
<span class="profile-private--message" tabindex="0"><%- gettext("You are currently sharing a limited profile.") %></span>
<% } else { %>
<span class="profile-private--message" tabindex="0"><%- gettext("This learner is currently sharing a limited profile.") %></span>
<% } %>
<% } %>
</div>
\ No newline at end of file
...@@ -138,7 +138,7 @@ if settings.FEATURES["ENABLE_MOBILE_REST_API"]: ...@@ -138,7 +138,7 @@ if settings.FEATURES["ENABLE_MOBILE_REST_API"]:
if settings.FEATURES["ENABLE_OPENBADGES"]: if settings.FEATURES["ENABLE_OPENBADGES"]:
urlpatterns += ( urlpatterns += (
url(r'^api/badges/v1/', include('badges.api.urls')), url(r'^api/badges/v1/', include('badges.api.urls', app_name="badges", namespace="badges_api")),
) )
js_info_dict = { js_info_dict = {
......
...@@ -24,19 +24,21 @@ from ..errors import ( ...@@ -24,19 +24,21 @@ from ..errors import (
) )
from ..forms import PasswordResetFormNoActive from ..forms import PasswordResetFormNoActive
from ..helpers import intercept_errors from ..helpers import intercept_errors
from ..models import UserPreference
from . import ( from . import (
ACCOUNT_VISIBILITY_PREF_KEY, PRIVATE_VISIBILITY,
EMAIL_MIN_LENGTH, EMAIL_MAX_LENGTH, PASSWORD_MIN_LENGTH, PASSWORD_MAX_LENGTH, EMAIL_MIN_LENGTH, EMAIL_MAX_LENGTH, PASSWORD_MIN_LENGTH, PASSWORD_MAX_LENGTH,
USERNAME_MIN_LENGTH, USERNAME_MAX_LENGTH USERNAME_MIN_LENGTH, USERNAME_MAX_LENGTH
) )
from .serializers import ( from .serializers import (
AccountLegacyProfileSerializer, AccountUserSerializer, AccountLegacyProfileSerializer, AccountUserSerializer,
UserReadOnlySerializer UserReadOnlySerializer, _visible_fields # pylint: disable=invalid-name
) )
# Public access point for this function.
visible_fields = _visible_fields
@intercept_errors(UserAPIInternalError, ignore_errors=[UserAPIRequestError]) @intercept_errors(UserAPIInternalError, ignore_errors=[UserAPIRequestError])
def get_account_settings(request, username=None, configuration=None, view=None): def get_account_settings(request, username=None, configuration=None, view=None):
"""Returns account information for a user serialized as JSON. """Returns account information for a user serialized as JSON.
......
...@@ -5,15 +5,15 @@ from rest_framework import serializers ...@@ -5,15 +5,15 @@ from rest_framework import serializers
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 django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from openedx.core.djangoapps.user_api.accounts import NAME_MIN_LENGTH
from openedx.core.djangoapps.user_api.serializers import ReadOnlyFieldsSerializerMixin
from student.models import UserProfile, LanguageProficiency
from ..models import UserPreference
from .image_helpers import get_profile_image_urls_for_user
from . import ( from . import (
ACCOUNT_VISIBILITY_PREF_KEY, ALL_USERS_VISIBILITY, PRIVATE_VISIBILITY, NAME_MIN_LENGTH, ACCOUNT_VISIBILITY_PREF_KEY, PRIVATE_VISIBILITY,
ALL_USERS_VISIBILITY,
) )
from openedx.core.djangoapps.user_api.models import UserPreference
from openedx.core.djangoapps.user_api.serializers import ReadOnlyFieldsSerializerMixin
from student.models import UserProfile, LanguageProficiency
from .image_helpers import get_profile_image_urls_for_user
PROFILE_IMAGE_KEY_PREFIX = 'image_url' PROFILE_IMAGE_KEY_PREFIX = 'image_url'
...@@ -52,7 +52,7 @@ class UserReadOnlySerializer(serializers.Serializer): ...@@ -52,7 +52,7 @@ class UserReadOnlySerializer(serializers.Serializer):
self.configuration = settings.ACCOUNT_VISIBILITY_CONFIGURATION self.configuration = settings.ACCOUNT_VISIBILITY_CONFIGURATION
# Don't pass the 'custom_fields' arg up to the superclass # Don't pass the 'custom_fields' arg up to the superclass
self.custom_fields = kwargs.pop('custom_fields', None) self.custom_fields = kwargs.pop('custom_fields', [])
super(UserReadOnlySerializer, self).__init__(*args, **kwargs) super(UserReadOnlySerializer, self).__init__(*args, **kwargs)
...@@ -63,6 +63,7 @@ class UserReadOnlySerializer(serializers.Serializer): ...@@ -63,6 +63,7 @@ class UserReadOnlySerializer(serializers.Serializer):
:return: Dict serialized account :return: Dict serialized account
""" """
profile = user.profile profile = user.profile
accomplishments_shared = settings.FEATURES.get('ENABLE_OPENBADGES') or False
data = { data = {
"username": user.username, "username": user.username,
...@@ -95,42 +96,19 @@ class UserReadOnlySerializer(serializers.Serializer): ...@@ -95,42 +96,19 @@ class UserReadOnlySerializer(serializers.Serializer):
"level_of_education": AccountLegacyProfileSerializer.convert_empty_to_None(profile.level_of_education), "level_of_education": AccountLegacyProfileSerializer.convert_empty_to_None(profile.level_of_education),
"mailing_address": profile.mailing_address, "mailing_address": profile.mailing_address,
"requires_parental_consent": profile.requires_parental_consent(), "requires_parental_consent": profile.requires_parental_consent(),
"account_privacy": self._get_profile_visibility(profile, user), "accomplishments_shared": accomplishments_shared,
"account_privacy": get_profile_visibility(profile, user, self.configuration),
} }
return self._filter_fields(
self._visible_fields(profile, user),
data
)
def _visible_fields(self, user_profile, user):
"""
Return what fields should be visible based on user settings
:param user_profile: User profile object
:param user: User object
:return: whitelist List of fields to be shown
"""
if self.custom_fields: if self.custom_fields:
return self.custom_fields fields = self.custom_fields
profile_visibility = self._get_profile_visibility(user_profile, user)
if profile_visibility == ALL_USERS_VISIBILITY:
return self.configuration.get('shareable_fields')
else: else:
return self.configuration.get('public_fields') fields = _visible_fields(profile, user, self.configuration)
def _get_profile_visibility(self, user_profile, user): return self._filter_fields(
"""Returns the visibility level for the specified user profile.""" fields,
if user_profile.requires_parental_consent(): data
return PRIVATE_VISIBILITY )
# Calling UserPreference directly because the requesting user may be different from existing_user
# (and does not have to be is_staff).
profile_privacy = UserPreference.get_value(user, ACCOUNT_VISIBILITY_PREF_KEY)
return profile_privacy if profile_privacy else self.configuration.get('default_visibility')
def _filter_fields(self, field_whitelist, serialized_account): def _filter_fields(self, field_whitelist, serialized_account):
""" """
...@@ -260,3 +238,37 @@ class AccountLegacyProfileSerializer(serializers.HyperlinkedModelSerializer, Rea ...@@ -260,3 +238,37 @@ class AccountLegacyProfileSerializer(serializers.HyperlinkedModelSerializer, Rea
]) ])
return instance return instance
def get_profile_visibility(user_profile, user, configuration=None):
"""Returns the visibility level for the specified user profile."""
if user_profile.requires_parental_consent():
return PRIVATE_VISIBILITY
if not configuration:
configuration = settings.ACCOUNT_VISIBILITY_CONFIGURATION
# Calling UserPreference directly because the requesting user may be different from existing_user
# (and does not have to be is_staff).
profile_privacy = UserPreference.get_value(user, ACCOUNT_VISIBILITY_PREF_KEY)
return profile_privacy if profile_privacy else configuration.get('default_visibility')
def _visible_fields(user_profile, user, configuration=None):
"""
Return what fields should be visible based on user settings
:param user_profile: User profile object
:param user: User object
:param configuration: A visibility configuration dictionary.
:return: whitelist List of fields to be shown
"""
if not configuration:
configuration = settings.ACCOUNT_VISIBILITY_CONFIGURATION
profile_visibility = get_profile_visibility(user_profile, user, configuration)
if profile_visibility == ALL_USERS_VISIBILITY:
return configuration.get('shareable_fields')
else:
return configuration.get('public_fields')
...@@ -87,7 +87,7 @@ class TestAccountApi(UserSettingsEventTestMixin, TestCase): ...@@ -87,7 +87,7 @@ class TestAccountApi(UserSettingsEventTestMixin, TestCase):
account_settings = get_account_settings( account_settings = get_account_settings(
self.default_request, self.default_request,
self.different_user.username, self.different_user.username,
configuration=config configuration=config,
) )
self.assertEqual(self.different_user.email, account_settings["email"]) self.assertEqual(self.different_user.email, account_settings["email"])
...@@ -282,6 +282,7 @@ class AccountSettingsOnCreationTest(TestCase): ...@@ -282,6 +282,7 @@ class AccountSettingsOnCreationTest(TestCase):
'requires_parental_consent': True, 'requires_parental_consent': True,
'language_proficiencies': [], 'language_proficiencies': [],
'account_privacy': PRIVATE_VISIBILITY, 'account_privacy': PRIVATE_VISIBILITY,
'accomplishments_shared': False,
}) })
......
...@@ -8,6 +8,7 @@ import datetime ...@@ -8,6 +8,7 @@ import datetime
import ddt import ddt
import hashlib import hashlib
import json import json
from mock import patch from mock import patch
from nose.plugins.attrib import attr from nose.plugins.attrib import attr
from pytz import UTC from pytz import UTC
...@@ -155,12 +156,12 @@ class TestAccountAPI(UserAPITestCase): ...@@ -155,12 +156,12 @@ class TestAccountAPI(UserAPITestCase):
} }
) )
def _verify_full_shareable_account_response(self, response, account_privacy=None): def _verify_full_shareable_account_response(self, response, account_privacy=None, badges_enabled=False):
""" """
Verify that the shareable fields from the account are returned Verify that the shareable fields from the account are returned
""" """
data = response.data data = response.data
self.assertEqual(7, len(data)) self.assertEqual(8, len(data))
self.assertEqual(self.user.username, data["username"]) self.assertEqual(self.user.username, data["username"])
self.assertEqual("US", data["country"]) self.assertEqual("US", data["country"])
self._verify_profile_image_data(data, True) self._verify_profile_image_data(data, True)
...@@ -168,6 +169,7 @@ class TestAccountAPI(UserAPITestCase): ...@@ -168,6 +169,7 @@ class TestAccountAPI(UserAPITestCase):
self.assertEqual([{"code": "en"}], data["language_proficiencies"]) self.assertEqual([{"code": "en"}], data["language_proficiencies"])
self.assertEqual("Tired mother of twins", data["bio"]) self.assertEqual("Tired mother of twins", data["bio"])
self.assertEqual(account_privacy, data["account_privacy"]) self.assertEqual(account_privacy, data["account_privacy"])
self.assertEqual(badges_enabled, data['accomplishments_shared'])
def _verify_private_account_response(self, response, requires_parental_consent=False, account_privacy=None): def _verify_private_account_response(self, response, requires_parental_consent=False, account_privacy=None):
""" """
...@@ -184,7 +186,7 @@ class TestAccountAPI(UserAPITestCase): ...@@ -184,7 +186,7 @@ class TestAccountAPI(UserAPITestCase):
Verify that all account fields are returned (even those that are not shareable). Verify that all account fields are returned (even those that are not shareable).
""" """
data = response.data data = response.data
self.assertEqual(16, len(data)) self.assertEqual(17, len(data))
self.assertEqual(self.user.username, data["username"]) self.assertEqual(self.user.username, data["username"])
self.assertEqual(self.user.first_name + " " + self.user.last_name, data["name"]) self.assertEqual(self.user.first_name + " " + self.user.last_name, data["name"])
self.assertEqual("US", data["country"]) self.assertEqual("US", data["country"])
...@@ -261,6 +263,7 @@ class TestAccountAPI(UserAPITestCase): ...@@ -261,6 +263,7 @@ class TestAccountAPI(UserAPITestCase):
response = self.send_get(self.different_client) response = self.send_get(self.different_client)
self._verify_private_account_response(response, account_privacy=PRIVATE_VISIBILITY) self._verify_private_account_response(response, account_privacy=PRIVATE_VISIBILITY)
@patch.dict(settings.FEATURES, {'ENABLE_OPENBADGES': True})
@ddt.data( @ddt.data(
("client", "user", PRIVATE_VISIBILITY), ("client", "user", PRIVATE_VISIBILITY),
("different_client", "different_user", PRIVATE_VISIBILITY), ("different_client", "different_user", PRIVATE_VISIBILITY),
...@@ -281,7 +284,7 @@ class TestAccountAPI(UserAPITestCase): ...@@ -281,7 +284,7 @@ class TestAccountAPI(UserAPITestCase):
if preference_visibility == PRIVATE_VISIBILITY: if preference_visibility == PRIVATE_VISIBILITY:
self._verify_private_account_response(response, account_privacy=PRIVATE_VISIBILITY) self._verify_private_account_response(response, account_privacy=PRIVATE_VISIBILITY)
else: else:
self._verify_full_shareable_account_response(response, ALL_USERS_VISIBILITY) self._verify_full_shareable_account_response(response, ALL_USERS_VISIBILITY, badges_enabled=True)
client = self.login_client(api_client, requesting_username) client = self.login_client(api_client, requesting_username)
...@@ -311,7 +314,7 @@ class TestAccountAPI(UserAPITestCase): ...@@ -311,7 +314,7 @@ class TestAccountAPI(UserAPITestCase):
with self.assertNumQueries(9): with self.assertNumQueries(9):
response = self.send_get(self.client) response = self.send_get(self.client)
data = response.data data = response.data
self.assertEqual(16, len(data)) self.assertEqual(17, len(data))
self.assertEqual(self.user.username, data["username"]) self.assertEqual(self.user.username, data["username"])
self.assertEqual(self.user.first_name + " " + self.user.last_name, data["name"]) self.assertEqual(self.user.first_name + " " + self.user.last_name, data["name"])
for empty_field in ("year_of_birth", "level_of_education", "mailing_address", "bio"): for empty_field in ("year_of_birth", "level_of_education", "mailing_address", "bio"):
...@@ -326,6 +329,8 @@ class TestAccountAPI(UserAPITestCase): ...@@ -326,6 +329,8 @@ class TestAccountAPI(UserAPITestCase):
self.assertTrue(data["requires_parental_consent"]) self.assertTrue(data["requires_parental_consent"])
self.assertEqual([], data["language_proficiencies"]) self.assertEqual([], data["language_proficiencies"])
self.assertEqual(PRIVATE_VISIBILITY, data["account_privacy"]) self.assertEqual(PRIVATE_VISIBILITY, data["account_privacy"])
# Badges aren't on by default, so should not be present.
self.assertEqual(False, data["accomplishments_shared"])
self.client.login(username=self.user.username, password=self.test_password) self.client.login(username=self.user.username, password=self.test_password)
verify_get_own_information() verify_get_own_information()
...@@ -693,7 +698,7 @@ class TestAccountAPI(UserAPITestCase): ...@@ -693,7 +698,7 @@ class TestAccountAPI(UserAPITestCase):
response = self.send_get(client) response = self.send_get(client)
if has_full_access: if has_full_access:
data = response.data data = response.data
self.assertEqual(16, len(data)) self.assertEqual(17, len(data))
self.assertEqual(self.user.username, data["username"]) self.assertEqual(self.user.username, data["username"])
self.assertEqual(self.user.first_name + " " + self.user.last_name, data["name"]) self.assertEqual(self.user.first_name + " " + self.user.last_name, data["name"])
self.assertEqual(self.user.email, data["email"]) self.assertEqual(self.user.email, data["email"])
......
...@@ -5,8 +5,9 @@ For more information, see: ...@@ -5,8 +5,9 @@ For more information, see:
https://openedx.atlassian.net/wiki/display/TNL/User+API https://openedx.atlassian.net/wiki/display/TNL/User+API
""" """
from django.db import transaction from django.db import transaction
from rest_framework import status, permissions
from rest_framework_jwt.authentication import JSONWebTokenAuthentication from rest_framework_jwt.authentication import JSONWebTokenAuthentication
from rest_framework import permissions
from rest_framework import status
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.views import APIView from rest_framework.views import APIView
...@@ -14,9 +15,9 @@ from openedx.core.lib.api.authentication import ( ...@@ -14,9 +15,9 @@ from openedx.core.lib.api.authentication import (
SessionAuthenticationAllowInactiveUser, SessionAuthenticationAllowInactiveUser,
OAuth2AuthenticationAllowInactiveUser, OAuth2AuthenticationAllowInactiveUser,
) )
from ..errors import UserNotFound, UserNotAuthorized, AccountUpdateError, AccountValidationError
from openedx.core.lib.api.parsers import MergePatchParser from openedx.core.lib.api.parsers import MergePatchParser
from .api import get_account_settings, update_account_settings from .api import get_account_settings, update_account_settings
from ..errors import UserNotFound, UserNotAuthorized, AccountUpdateError, AccountValidationError
class AccountView(APIView): class AccountView(APIView):
...@@ -97,6 +98,8 @@ class AccountView(APIView): ...@@ -97,6 +98,8 @@ class AccountView(APIView):
* year_of_birth: The year the user was born, as an integer, or null. * year_of_birth: The year the user was born, as an integer, or null.
* account_privacy: The user's setting for sharing her personal * account_privacy: The user's setting for sharing her personal
profile. Possible values are "all_users" or "private". profile. Possible values are "all_users" or "private".
* accomplishments_shared: Signals whether badges are enabled on the
platform and should be fetched.
For all text fields, plain text instead of HTML is supported. The For all text fields, plain text instead of HTML is supported. The
data is stored exactly as specified. Clients must HTML escape data is stored exactly as specified. Clients must HTML escape
...@@ -148,7 +151,8 @@ class AccountView(APIView): ...@@ -148,7 +151,8 @@ class AccountView(APIView):
GET /api/user/v1/accounts/{username}/ GET /api/user/v1/accounts/{username}/
""" """
try: try:
account_settings = get_account_settings(request, username, view=request.query_params.get('view')) account_settings = get_account_settings(
request, username, view=request.query_params.get('view'))
except UserNotFound: except UserNotFound:
return Response(status=status.HTTP_403_FORBIDDEN if request.user.is_staff else status.HTTP_404_NOT_FOUND) return Response(status=status.HTTP_403_FORBIDDEN if request.user.is_staff else status.HTTP_404_NOT_FOUND)
......
"""
Permissions classes for User-API aware views.
"""
from django.contrib.auth.models import User
from django.http import Http404
from django.shortcuts import get_object_or_404
from rest_framework import permissions
from openedx.core.djangoapps.user_api.accounts.api import visible_fields
def is_field_shared_factory(field_name):
"""
Generates a permission class that grants access if a particular profile field is
shared with the requesting user.
"""
class IsFieldShared(permissions.BasePermission):
"""
Grants access if a particular profile field is shared with the requesting user.
"""
def has_permission(self, request, view):
url_username = request.parser_context.get('kwargs', {}).get('username', '')
if request.user.username.lower() == url_username.lower():
return True
# Staff can always see profiles.
if request.user.is_staff:
return True
# This should never return Multiple, as we don't allow case name collisions on registration.
user = get_object_or_404(User, username__iexact=url_username)
if field_name in visible_fields(user.profile, user):
return True
raise Http404()
return IsFieldShared
...@@ -15,6 +15,7 @@ class DefaultPagination(pagination.PageNumberPagination): ...@@ -15,6 +15,7 @@ class DefaultPagination(pagination.PageNumberPagination):
by any subclass of Django Rest Framework's generic API views. by any subclass of Django Rest Framework's generic API views.
""" """
page_size_query_param = "page_size" page_size_query_param = "page_size"
max_page_size = 100
def get_paginated_response(self, data): def get_paginated_response(self, data):
""" """
...@@ -25,6 +26,8 @@ class DefaultPagination(pagination.PageNumberPagination): ...@@ -25,6 +26,8 @@ class DefaultPagination(pagination.PageNumberPagination):
'previous': self.get_previous_link(), 'previous': self.get_previous_link(),
'count': self.page.paginator.count, 'count': self.page.paginator.count,
'num_pages': self.page.paginator.num_pages, 'num_pages': self.page.paginator.num_pages,
'current_page': self.page.number,
'start': (self.page.number - 1) * self.get_page_size(self.request),
'results': data 'results': data
}) })
......
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