Commit a04a635e by Jonathan Piacenti

Add accomplishments to user profile

parent a2104634
......@@ -21,10 +21,32 @@
define(['backbone.paginator'], function (BackbonePaginator) {
var PagingCollection = BackbonePaginator.requestPager.extend({
initialize: function () {
var self = this;
// These must be initialized in the constructor because otherwise all PagingCollections would point
// to the same object references for sortableFields and filterableFields.
this.sortableFields = {};
this.filterableFields = {};
this.paginator_core = {
type: 'GET',
dataType: 'json',
url: function () { return this.url; }
};
this.paginator_ui = {
firstPage: function () { return self.isZeroIndexed ? 0 : 1; },
// Specifies the initial page during collection initialization
currentPage: self.isZeroIndexed ? 0 : 1,
perPage: function () { return self.perPage; }
};
this.currentPage = this.paginator_ui.currentPage;
this.server_api = {
page: function () { return self.currentPage; },
page_size: function () { return self.perPage; },
text_search: function () { return self.searchString ? self.searchString : ''; },
sort_order: function () { return self.sortField; }
};
},
isZeroIndexed: false,
......@@ -41,26 +63,6 @@
searchString: null,
paginator_core: {
type: 'GET',
dataType: 'json',
url: function () { return this.url; }
},
paginator_ui: {
firstPage: function () { return this.isZeroIndexed ? 0 : 1; },
// Specifies the initial page during collection initialization
currentPage: function () { return this.isZeroIndexed ? 0 : 1; },
perPage: function () { return this.perPage; }
},
server_api: {
page: function () { return this.currentPage; },
page_size: function () { return this.perPage; },
text_search: function () { return this.searchString ? this.searchString : ''; },
sort_order: function () { return this.sortField; }
},
parse: function (response) {
this.totalCount = response.count;
this.currentPage = response.current_page;
......
......@@ -24,18 +24,26 @@
this.itemViews = [];
},
renderCollection: function() {
/**
* Render every item in the collection.
* This should push each rendered item to this.itemViews
* to ensure garbage collection works.
*/
this.collection.each(function (model) {
var itemView = new this.itemViewClass({model: model});
this.$el.append(itemView.render().el);
this.itemViews.push(itemView);
}, this);
},
render: function () {
// Remove old children views
_.each(this.itemViews, function (childView) {
childView.remove();
});
this.itemViews = [];
// Render the collection
this.collection.each(function (model) {
var itemView = new this.itemViewClass({model: model});
this.$el.append(itemView.render().el);
this.itemViews.push(itemView);
}, this);
this.renderCollection();
return this;
}
});
......
......@@ -26,7 +26,7 @@
], function (Backbone, _, PagingHeader, PagingFooter, ListView, paginatedViewTemplate) {
var PaginatedView = Backbone.View.extend({
initialize: function () {
var ItemListView = ListView.extend({
var ItemListView = this.listViewClass.extend({
tagName: 'div',
className: this.type + '-container',
itemViewClass: this.itemViewClass
......@@ -39,18 +39,25 @@
}, this);
},
listViewClass: ListView,
viewTemplate: paginatedViewTemplate,
paginationLabel: gettext("Pagination"),
createHeaderView: function() {
return new PagingHeader({collection: this.options.collection, srInfo: this.srInfo});
},
createFooterView: function() {
return new PagingFooter({
collection: this.options.collection, hideWhenOnePage: true
collection: this.options.collection, hideWhenOnePage: true,
paginationLabel: this.paginationLabel
});
},
render: function () {
this.$el.html(_.template(paginatedViewTemplate)({type: this.type}));
this.$el.html(_.template(this.viewTemplate)({type: this.type}));
this.assign(this.listView, '.' + this.type + '-list');
if (this.headerView) {
this.assign(this.headerView, '.' + this.type + '-paging-header');
......
......@@ -13,6 +13,7 @@
initialize: function(options) {
this.collection = options.collection;
this.hideWhenOnePage = options.hideWhenOnePage || false;
this.paginationLabel = options.paginationLabel || gettext("Pagination");
this.collection.bind('add', _.bind(this.render, this));
this.collection.bind('remove', _.bind(this.render, this));
this.collection.bind('reset', _.bind(this.render, this));
......@@ -32,7 +33,8 @@
}
this.$el.html(_.template(paging_footer_template)({
current_page: this.collection.getPage(),
total_pages: this.collection.totalPages
total_pages: this.collection.totalPages,
paginationLabel: this.paginationLabel
}));
this.$(".previous-page-link").toggleClass("is-disabled", onFirstPage).attr('aria-disabled', onFirstPage);
this.$(".next-page-link").toggleClass("is-disabled", onLastPage).attr('aria-disabled', onLastPage);
......
......@@ -3,9 +3,9 @@
define(['backbone',
'underscore',
'jquery',
'text!templates/components/tabbed/tabbed_view.underscore',
'text!templates/components/tabbed/tab.underscore',
'text!templates/components/tabbed/tabpanel.underscore',
'text!common/templates/components/tabbed_view.underscore',
'text!common/templates/components/tab.underscore',
'text!common/templates/components/tabpanel.underscore',
], function (
Backbone,
_,
......@@ -37,8 +37,6 @@
'click .nav-item.tab': 'switchTab'
},
template: _.template(tabbedViewTemplate),
/**
* View for a tabbed interface. Expects a list of tabs
* in its options object, each of which should contain the
......@@ -51,12 +49,13 @@
* If a router is passed in (via options.router),
* use that router to keep track of history between
* tabs. Backbone.history.start() must be called
* by the router's instatiator after this view is
* by the router's instantiator after this view is
* initialized.
*/
initialize: function (options) {
this.router = options.router || null;
this.tabs = options.tabs;
this.template = _.template(tabbedViewTemplate)({viewLabel: options.viewLabel});
// Convert each view into a TabPanelView
_.each(this.tabs, function (tabInfo) {
tabInfo.view = new TabPanelView({url: tabInfo.url, view: tabInfo.view});
......@@ -69,7 +68,7 @@
render: function () {
var self = this;
this.$el.html(this.template({}));
this.$el.html(this.template);
_.each(this.tabs, function(tabInfo, index) {
var tabEl = $(_.template(tabTemplate)({
index: index,
......
......@@ -4,7 +4,7 @@
define(['jquery',
'underscore',
'backbone',
'js/components/tabbed/views/tabbed_view'
'common/js/components/views/tabbed_view'
],
function($, _, Backbone, TabbedView) {
var view,
......@@ -36,7 +36,8 @@
title: 'Test 2',
view: new TestSubview({text: 'other text'}),
url: 'test-2'
}]
}],
viewLabel: 'Tabs',
}).render();
// _.defer() is used to make calls to
......
......@@ -155,6 +155,7 @@
define([
// Run the common tests that use RequireJS.
'common-requirejs/include/common/js/spec/components/tabbed_view_spec.js',
'common-requirejs/include/common/js/spec/components/feedback_spec.js',
'common-requirejs/include/common/js/spec/components/list_spec.js',
'common-requirejs/include/common/js/spec/components/paginated_view_spec.js',
......
......@@ -72,6 +72,10 @@ define(['sinon', 'underscore', 'URI'], function(sinon, _, URI) {
expect(request.readyState).toEqual(XML_HTTP_READY_STATES.OPENED);
expect(request.url).toEqual(url);
expect(request.method).toEqual(method);
if (typeof body === 'undefined') {
// The contents if this call may not be germane to the current test.
return;
}
expect(request.requestBody).toEqual(body);
};
......
<nav class="pagination pagination-full bottom" aria-label="Pagination">
<nav class="pagination pagination-full bottom" aria-label="<%= paginationLabel %>">
<div class="nav-item previous"><button class="nav-link previous-page-link"><i class="icon fa fa-angle-left" aria-hidden="true"></i> <span class="nav-label"><%= gettext("Previous") %></span></button></div>
<div class="nav-item page">
<div class="pagination-form">
<label class="page-number-label" for="page-number-input"><%= gettext("Page number") %></label>
<label class="page-number-label" for="page-number-input"><%= interpolate(
gettext("Page number out of %(total_pages)s"),
{total_pages: total_pages},
true
)%></label>
<input id="page-number-input" class="page-number-input" name="page-number" type="text" size="4" autocomplete="off" aria-describedby="page-number-input-helper"/>
<span class="sr field-helper" id="page-number-input-helper"><%= gettext("Enter the page number you'd like to quickly navigate to.") %></span>
</div>
......
<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="tabs"></div>
</div>
"""
Serializers for Badges
Serializers for badging
"""
from rest_framework import serializers
......@@ -25,4 +25,4 @@ class BadgeAssertionSerializer(serializers.ModelSerializer):
class Meta(object):
model = BadgeAssertion
fields = ('badge_class', 'image_url', 'assertion_url')
fields = ('badge_class', 'image_url', 'assertion_url', 'created')
......@@ -6,8 +6,12 @@ from opaque_keys.edx.keys import CourseKey
from rest_framework import generics
from rest_framework.exceptions import APIException
from openedx.core.djangoapps.user_api.permissions import is_field_shared_factory
from openedx.core.lib.api.authentication import (
OAuth2AuthenticationAllowInactiveUser,
SessionAuthenticationAllowInactiveUser
)
from badges.models import BadgeAssertion
from openedx.core.lib.api.view_utils import view_auth_classes
from .serializers import BadgeAssertionSerializer
from xmodule_django.models import CourseKeyField
......@@ -20,7 +24,6 @@ class CourseKeyError(APIException):
default_detail = "The course key provided could not be parsed."
@view_auth_classes(is_user=True)
class UserBadgeAssertions(generics.ListAPIView):
"""
** Use cases **
......@@ -89,6 +92,17 @@ class UserBadgeAssertions(generics.ListAPIView):
}
"""
serializer_class = BadgeAssertionSerializer
authentication_classes = (
OAuth2AuthenticationAllowInactiveUser,
SessionAuthenticationAllowInactiveUser
)
permission_classes = (is_field_shared_factory("accomplishments_shared"),)
def filter_queryset(self, queryset):
"""
Return most recent to least recent badge.
"""
return queryset.order_by('-created')
def get_queryset(self):
"""
......
......@@ -5,6 +5,8 @@ from django.db import migrations, models
import jsonfield.fields
import badges.models
from django.conf import settings
import django.utils.timezone
from model_utils import fields
import xmodule_django.models
......@@ -23,14 +25,16 @@ class Migration(migrations.Migration):
('backend', models.CharField(max_length=50)),
('image_url', models.URLField()),
('assertion_url', models.URLField()),
('modified', fields.AutoLastModifiedField(default=django.utils.timezone.now, verbose_name='modified', editable=False)),
('created', fields.AutoCreatedField(default=django.utils.timezone.now, verbose_name='created', editable=False, db_index=True)),
],
),
migrations.CreateModel(
name='BadgeClass',
fields=[
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
('slug', models.SlugField(max_length=255)),
('issuing_component', models.SlugField(default=b'', blank=True)),
('slug', models.SlugField(max_length=255, validators=[badges.models.validate_lowercase])),
('issuing_component', models.SlugField(default=b'', blank=True, validators=[badges.models.validate_lowercase])),
('display_name', models.CharField(max_length=255)),
('course_id', xmodule_django.models.CourseKeyField(default=None, max_length=255, blank=True)),
('description', models.TextField()),
......
......@@ -2,8 +2,10 @@
from __future__ import unicode_literals
import json
from datetime import datetime
import os
import time
from django.db import migrations, models
......@@ -47,14 +49,20 @@ def forwards(apps, schema_editor):
data = badge.data
else:
data = json.dumps(badge.data)
BadgeAssertion(
assertion = BadgeAssertion(
user_id=badge.user_id,
badge_class=classes[(badge.course_id, badge.mode)],
data=data,
backend='BadgrBackend',
image_url=badge.data['image'],
assertion_url=badge.data['json']['id'],
).save()
)
assertion.save()
# Would be overwritten by the first save.
assertion.created = datetime.fromtimestamp(
time.mktime(time.strptime(badge.data['created_at'], "%Y-%m-%dT%H:%M:%S"))
)
assertion.save()
for configuration in BadgeImageConfiguration.objects.all():
file_content = ContentFile(configuration.icon.read())
......
......@@ -20,9 +20,9 @@ class Migration(migrations.Migration):
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
('change_date', models.DateTimeField(auto_now_add=True, verbose_name='Change date')),
('enabled', models.BooleanField(default=False, verbose_name='Enabled')),
('courses_completed', models.TextField(default=b'', help_text="On each line, put the number of completed courses to award a badge for, a comma, and the slug of a badge class you have created with the issuing component 'edx__course'. For example: 3,course-v1:edx/Demo/DemoX", blank=True)),
('courses_enrolled', models.TextField(default=b'', help_text="On each line, put the number of enrolled courses to award a badge for, a comma, and the slug of a badge class you have created with the issuing component 'edx__course'. For example: 3,course-v1:edx/Demo/DemoX", blank=True)),
('course_groups', models.TextField(default=b'', help_text="On each line, put the slug of a badge class you have created with the issuing component 'edx__course' to award, a comma, and a comma separated list of course keys that the user will need to complete to get this badge. For example: slug_for_compsci_courses_group_badge,course-v1:CompSci+Course+First,course-v1:CompsSci+Course+Second", blank=True)),
('courses_completed', models.TextField(default=b'', help_text="On each line, put the number of completed courses to award a badge for, a comma, and the slug of a badge class you have created with the issuing component 'edx__course'. For example: 3,enrolled_3_courses", blank=True)),
('courses_enrolled', models.TextField(default=b'', help_text="On each line, put the number of enrolled courses to award a badge for, a comma, and the slug of a badge class you have created with the issuing component 'edx__course'. For example: 3,enrolled_3_courses", blank=True)),
('course_groups', models.TextField(default=b'', help_text="Each line is a comma-separated list. The first item in each line is the slug of a badge class to award, with an issuing component of 'edx__course'. The remaining items in each line are the course keys the user will need to complete to be awarded the badge. For example: slug_for_compsci_courses_group_badge,course-v1:CompSci+Course+First,course-v1:CompsSci+Course+Second", blank=True)),
('changed_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, editable=False, to=settings.AUTH_USER_MODEL, null=True, verbose_name='Changed by')),
],
),
......
......@@ -9,6 +9,7 @@ from django.core.exceptions import ValidationError
from django.db import models
from django.utils.translation import ugettext_lazy as _
from lazy import lazy
from model_utils.models import TimeStampedModel
from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import CourseKey
......@@ -122,7 +123,7 @@ class BadgeClass(models.Model):
verbose_name_plural = "Badge Classes"
class BadgeAssertion(models.Model):
class BadgeAssertion(TimeStampedModel):
"""
Tracks badges on our side of the badge baking transaction
"""
......@@ -152,6 +153,10 @@ class BadgeAssertion(models.Model):
app_label = "badges"
# Abstract model doesn't index this, so we have to.
BadgeAssertion._meta.get_field('created').db_index = True # pylint: disable=protected-access
class CourseCompleteImageConfiguration(models.Model):
"""
Contains the icon configuration for badges for a specific course mode.
......@@ -216,7 +221,7 @@ class CourseEventBadgesConfiguration(ConfigurationModel):
help_text=_(
u"On each line, put the number of completed courses to award a badge for, a comma, and the slug of a "
u"badge class you have created with the issuing component 'edx__course'. "
u"For example: 3,course-v1:edx/Demo/DemoX"
u"For example: 3,enrolled_3_courses"
)
)
courses_enrolled = models.TextField(
......@@ -224,7 +229,7 @@ class CourseEventBadgesConfiguration(ConfigurationModel):
help_text=_(
u"On each line, put the number of enrolled courses to award a badge for, a comma, and the slug of a "
u"badge class you have created with the issuing component 'edx__course'. "
u"For example: 3,course-v1:edx/Demo/DemoX"
u"For example: 3,enrolled_3_courses"
)
)
course_groups = models.TextField(
......@@ -232,8 +237,8 @@ class CourseEventBadgesConfiguration(ConfigurationModel):
help_text=_(
u"Each line is a comma-separated list. The first item in each line is the slug of a badge class to award, "
u"with an issuing component of 'edx__course'. The remaining items in each line are the course keys the "
u"user will need to complete to get the badge. For example: slug_for_compsci_courses_group_badge,course-v1"
u":CompSci+Course+First,course-v1:CompsSci+Course+Second"
u"user will need to complete to be awarded the badge. For example: "
u"slug_for_compsci_courses_group_badge,course-v1:CompSci+Course+First,course-v1:CompsSci+Course+Second"
)
)
......
""" Views for a student's profile information. """
from django.conf import settings
from django.contrib.auth.decorators import login_required
from django.core.exceptions import ObjectDoesNotExist
from django_countries import countries
from django.core.urlresolvers import reverse
from django.contrib.auth.decorators import login_required
from django.http import Http404
from django.views.decorators.http import require_http_methods
from django_countries import countries
from edxmako.shortcuts import render_to_response
from edxmako.shortcuts import render_to_response, marketing_link
from microsite_configuration import microsite
from openedx.core.djangoapps.user_api.accounts.api import get_account_settings
from openedx.core.djangoapps.user_api.accounts.serializers import PROFILE_IMAGE_KEY_PREFIX
from openedx.core.djangoapps.user_api.errors import UserNotFound, UserNotAuthorized
from openedx.core.djangoapps.user_api.preferences.api import get_user_preferences
from student.models import User
from microsite_configuration import microsite
@login_required
......@@ -87,9 +85,14 @@ def learner_profile_context(request, profile_username, user_is_staff):
'has_preferences_access': (logged_in_user.username == profile_username or user_is_staff),
'own_profile': own_profile,
'country_options': list(countries),
'find_courses_url': marketing_link('COURSES'),
'language_options': settings.ALL_LANGUAGES,
'platform_name': microsite.get_value('platform_name', settings.PLATFORM_NAME),
},
'disable_courseware_js': True,
}
if settings.FEATURES.get("ENABLE_OPENBADGES"):
context['data']['badges_api_url'] = reverse("badges_api:user_assertions", kwargs={'username': profile_username})
return context
......@@ -10,13 +10,13 @@
BaseCollection.prototype.initialize.call(this, options);
this.server_api = _.extend(
this.server_api,
{
topic_id: this.topic_id = options.topic_id,
expand: 'user',
course_id: function () { return encodeURIComponent(self.course_id); },
order_by: function () { return self.searchString ? '' : this.sortField; }
},
BaseCollection.prototype.server_api
}
);
delete this.server_api.sort_order; // Sort order is not specified for the Team API
......
......@@ -11,11 +11,11 @@
this.perPage = topics.results.length;
this.server_api = _.extend(
this.server_api,
{
course_id: function () { return encodeURIComponent(self.course_id); },
order_by: function () { return this.sortField; }
},
BaseCollection.prototype.server_api
}
);
delete this.server_api['sort_order']; // Sort order is not specified for the Team API
......
......@@ -5,7 +5,8 @@
return function (options) {
var teamsTab = new TeamsTabView({
el: $('.teams-content'),
context: options
context: options,
viewLabel: gettext("Teams")
});
teamsTab.start();
};
......
......@@ -15,6 +15,8 @@
text: gettext('All teams')
},
paginationLabel: gettext('Teams Pagination'),
initialize: function (options) {
this.context = options.context;
this.itemViewClass = TeamCardView.extend({
......
......@@ -5,7 +5,7 @@
'use strict';
define([
'js/components/tabbed/views/tabbed_view',
'common/js/components/views/tabbed_view',
'teams/js/utils/team_analytics'
], function (TabbedView, TeamAnalytics) {
var TeamsTabbedView = TabbedView.extend({
......
......@@ -77,37 +77,12 @@ def team_post_save_callback(sender, instance, **kwargs): # pylint: disable=unus
)
class TeamAPIPagination(DefaultPagination):
"""
Pagination format used by the teams API.
"""
page_size_query_param = "page_size"
def get_paginated_response(self, data):
"""
Annotate the response with pagination information.
"""
response = super(TeamAPIPagination, self).get_paginated_response(data)
# Add the current page to the response.
# It may make sense to eventually move this field into the default
# implementation, but for now, teams is the only API that uses this.
response.data["current_page"] = self.page.number
# This field can be derived from other fields in the response,
# so it may make sense to have the JavaScript client calculate it
# instead of including it in the response.
response.data["start"] = (self.page.number - 1) * self.get_page_size(self.request)
return response
class TopicsPagination(TeamAPIPagination):
class TopicsPagination(DefaultPagination):
"""Paginate topics. """
page_size = TOPICS_PER_PAGE
class MyTeamsPagination(TeamAPIPagination):
class MyTeamsPagination(DefaultPagination):
"""Paginate the user's teams. """
page_size = TEAM_MEMBERSHIPS_PER_PAGE
......@@ -381,7 +356,6 @@ class TeamsListView(ExpandableFieldViewMixin, GenericAPIView):
authentication_classes = (OAuth2Authentication, SessionAuthentication)
permission_classes = (permissions.IsAuthenticated,)
serializer_class = CourseTeamSerializer
pagination_class = TeamAPIPagination
def get(self, request):
"""GET /api/team/v0/teams/"""
......
......@@ -2644,6 +2644,8 @@ ACCOUNT_VISIBILITY_CONFIGURATION = {
'language_proficiencies',
'bio',
'account_privacy',
# Not an actual field, but used to signal whether badges should be public.
'accomplishments_shared',
],
# The list of account fields that are always public
......@@ -2671,6 +2673,7 @@ ACCOUNT_VISIBILITY_CONFIGURATION = {
"mailing_address",
"requires_parental_consent",
"account_privacy",
"accomplishments_shared",
]
}
......
......@@ -13,7 +13,7 @@
course_id: function () { return encodeURIComponent(options.course_id); },
fields : function () { return encodeURIComponent('display_name,path'); }
},
PagingCollection.prototype.server_api
this.server_api
);
delete this.server_api.sort_order; // Sort order is not specified for the Bookmark API
},
......
......@@ -10,7 +10,7 @@ define([
PagingCollection.prototype.initialize.call(this);
this.perPage = options.perPage;
this.server_api = _.pick(PagingCollection.prototype.server_api, "page", "page_size");
this.server_api = _.pick(this.server_api, "page", "page_size");
if (options.text) {
this.server_api.text = options.text;
}
......
......@@ -633,7 +633,6 @@
define([
// Run the LMS tests
'lms/include/js/spec/components/header/header_spec.js',
'lms/include/js/spec/components/tabbed/tabbed_view_spec.js',
'lms/include/js/spec/components/card/card_spec.js',
'lms/include/js/spec/staff_debug_actions_spec.js',
'lms/include/js/spec/views/notification_spec.js',
......
......@@ -3,8 +3,10 @@ define(['underscore'], function(_) {
var USER_ACCOUNTS_API_URL = '/api/user/v0/accounts/student';
var USER_PREFERENCES_API_URL = '/api/user/v0/preferences/student';
var BADGES_API_URL = '/api/badges/v1/assertions/student/';
var IMAGE_UPLOAD_API_URL = '/api/profile_images/v0/staff/upload';
var IMAGE_REMOVE_API_URL = '/api/profile_images/v0/staff/remove';
var FIND_COURSES_URL = '/courses';
var PROFILE_IMAGE = {
image_url_large: '/media/profile-images/image.jpg',
......@@ -23,7 +25,8 @@ define(['underscore'], function(_) {
language: null,
bio: "About the student",
language_proficiencies: [{code: '1'}],
profile_image: PROFILE_IMAGE
profile_image: PROFILE_IMAGE,
accomplishments_shared: false
};
var createAccountSettingsData = function(options) {
......@@ -109,6 +112,8 @@ define(['underscore'], function(_) {
return {
USER_ACCOUNTS_API_URL: USER_ACCOUNTS_API_URL,
USER_PREFERENCES_API_URL: USER_PREFERENCES_API_URL,
BADGES_API_URL: BADGES_API_URL,
FIND_COURSES_URL: FIND_COURSES_URL,
IMAGE_UPLOAD_API_URL: IMAGE_UPLOAD_API_URL,
IMAGE_REMOVE_API_URL: IMAGE_REMOVE_API_URL,
IMAGE_MAX_BYTES: IMAGE_MAX_BYTES,
......
......@@ -93,7 +93,7 @@ define(['underscore'], function(_) {
if (othersProfile) {
expect($('.profile-private--message').text())
.toBe('This edX learner is currently sharing a limited profile.');
.toBe('This learner is currently sharing a limited profile.');
} else {
expect($('.profile-private--message').text()).toBe('You are currently sharing a limited profile.');
}
......@@ -105,9 +105,122 @@ define(['underscore'], function(_) {
expect(learnerProfileView.$('.wrapper-profile-section-two').length).toBe(0);
};
var expectTabbedViewToBeHidden = function(requests, tabbedViewView) {
// Unrelated initial request, no badge request
expect(requests.length).toBe(1);
expect(tabbedViewView.$el.find('.page-content-nav').is(':visible')).toBe(false);
};
var expectTabbedViewToBeShown = function(tabbedViewView) {
expect(tabbedViewView.$el.find('.page-content-nav').is(':visible')).toBe(true);
};
var expectBadgesDisplayed = function(learnerProfileView, length, lastPage) {
var badgeListingView = learnerProfileView.$el.find('#tabpanel-accomplishments');
expect(learnerProfileView.$el.find('#tabpanel-about_me').hasClass('is-hidden')).toBe(true);
expect(badgeListingView.hasClass('is-hidden')).toBe(false);
if (lastPage) {
length += 1;
var placeholder = badgeListingView.find('.find-course');
expect(placeholder.length).toBe(1);
expect(placeholder.attr('href')).toBe('/courses/');
}
expect(badgeListingView.find('.badge-display').length).toBe(length);
};
var expectBadgesHidden = function(learnerProfileView) {
var accomplishmentsTab = learnerProfileView.$el.find('#tabpanel-accomplishments');
if (accomplishmentsTab.length) {
// Nonexistence counts as hidden.
expect(learnerProfileView.$el.find('#tabpanel-accomplishments').hasClass('is-hidden')).toBe(true);
}
expect(learnerProfileView.$el.find('#tabpanel-about_me').hasClass('is-hidden')).toBe(false);
};
var expectPage = function(learnerProfileView, pageData) {
var badgeListContainer = learnerProfileView.$el.find('#tabpanel-accomplishments');
var index = badgeListContainer.find('span.search-count').text().trim();
expect(index).toBe("Showing " + (pageData.start + 1) + "-" + (pageData.start + pageData.results.length) +
" out of " + pageData.count + " total");
expect(badgeListContainer.find('.current-page').text()).toBe("" + pageData.current_page);
_.each(pageData.results, function(badge) {
expect($(".badge-display:contains(" + badge.badge_class.display_name + ")").length).toBe(1);
});
};
var firstPageBadges = {
count: 30,
previous: null,
next: "/arbitrary/url",
num_pages: 3,
start: 0,
current_page: 1,
results: []
};
var secondPageBadges = {
count: 30,
previous: "/arbitrary/url",
next: "/arbitrary/url",
num_pages: 3,
start: 10,
current_page: 2,
results: []
};
var thirdPageBadges = {
count: 30,
previous: "/arbitrary/url",
num_pages: 3,
next: null,
start: 20,
current_page: 3,
results: []
};
function makeBadge (num) {
return {
"badge_class": {
"slug": "test_slug_" + num,
"issuing_component": "test_component",
"display_name": "Test Badge " + num,
"course_id": null,
"description": "Yay! It's a test badge.",
"criteria": "https://example.com/syllabus",
"image_url": "http://localhost:8000/media/badge_classes/test_lMB9bRw.png"
},
"image_url": "http://example.com/image.png",
"assertion_url": "http://example.com/example.json",
"created_at": "2015-12-03T16:25:57.676113Z"
};
}
_.each(_.range(0, 10), function(i) {
firstPageBadges.results.push(makeBadge(i));
});
_.each(_.range(10, 20), function(i) {
secondPageBadges.results.push(makeBadge(i));
});
_.each(_.range(20, 30), function(i) {
thirdPageBadges.results.push(makeBadge(i));
});
var emptyBadges = {
"count": 0,
"previous": null,
"num_pages": 1,
"results": []
};
return {
expectLimitedProfileSectionsAndFieldsToBeRendered: expectLimitedProfileSectionsAndFieldsToBeRendered,
expectProfileSectionsAndFieldsToBeRendered: expectProfileSectionsAndFieldsToBeRendered,
expectProfileSectionsNotToBeRendered: expectProfileSectionsNotToBeRendered
expectProfileSectionsNotToBeRendered: expectProfileSectionsNotToBeRendered,
expectTabbedViewToBeHidden: expectTabbedViewToBeHidden, expectTabbedViewToBeShown: expectTabbedViewToBeShown,
expectBadgesDisplayed: expectBadgesDisplayed, expectBadgesHidden: expectBadgesHidden,
firstPageBadges: firstPageBadges, secondPageBadges: secondPageBadges, thirdPageBadges: thirdPageBadges,
emptyBadges: emptyBadges, expectPage: expectPage
};
});
......@@ -6,12 +6,14 @@ define(['backbone', 'jquery', 'underscore', 'common/js/spec_helpers/ajax_helpers
'js/student_account/models/user_preferences_model',
'js/student_profile/views/learner_profile_fields',
'js/student_profile/views/learner_profile_view',
'js/student_profile/views/badge_list_container',
'js/student_account/views/account_settings_fields',
'common/js/components/collections/paging_collection',
'js/views/message_banner'
],
function (Backbone, $, _, AjaxHelpers, TemplateHelpers, Helpers, LearnerProfileHelpers, FieldViews,
UserAccountModel, AccountPreferencesModel, LearnerProfileFields, LearnerProfileView,
AccountSettingsFieldViews, MessageBannerView) {
BadgeListContainer, AccountSettingsFieldViews, PagingCollection, MessageBannerView) {
'use strict';
describe("edx.user.LearnerProfileView", function () {
......@@ -106,6 +108,15 @@ define(['backbone', 'jquery', 'underscore', 'common/js/spec_helpers/ajax_helpers
})
];
var badgeCollection = new PagingCollection();
badgeCollection.url = Helpers.BADGES_API_URL;
var badgeListContainer = new BadgeListContainer({
'attributes': {'class': 'badge-set-display'},
'collection': badgeCollection,
'find_courses_url': Helpers.FIND_COURSES_URL
});
return new LearnerProfileView(
{
el: $('.wrapper-profile'),
......@@ -117,7 +128,8 @@ define(['backbone', 'jquery', 'underscore', 'common/js/spec_helpers/ajax_helpers
usernameFieldView: usernameFieldView,
profileImageFieldView: profileImageFieldView,
sectionOneFieldViews: sectionOneFieldViews,
sectionTwoFieldViews: sectionTwoFieldViews
sectionTwoFieldViews: sectionTwoFieldViews,
badgeListContainer: badgeListContainer
});
};
......@@ -125,6 +137,10 @@ define(['backbone', 'jquery', 'underscore', 'common/js/spec_helpers/ajax_helpers
loadFixtures('js/fixtures/student_profile/student_profile.html');
});
afterEach(function () {
Backbone.history.stop();
});
it("shows loading error correctly", function() {
var learnerProfileView = createLearnerProfileView(false, 'all_users');
......@@ -189,5 +205,6 @@ define(['backbone', 'jquery', 'underscore', 'common/js/spec_helpers/ajax_helpers
Helpers.expectLoadingErrorIsVisible(learnerProfileView, false);
LearnerProfileHelpers.expectLimitedProfileSectionsAndFieldsToBeRendered(learnerProfileView, true);
});
});
});
......@@ -23,6 +23,7 @@
language_proficiencies: [],
requires_parental_consent: true,
profile_image: null,
accomplishments_shared: false,
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 @@
'js/views/fields',
'js/student_profile/views/learner_profile_fields',
'js/student_profile/views/learner_profile_view',
'js/student_profile/models/badges_model',
'common/js/components/collections/paging_collection',
'js/student_profile/views/badge_list_container',
'js/student_account/views/account_settings_fields',
'js/views/message_banner',
'string_utils'
], function (gettext, $, _, Backbone, Logger, AccountSettingsModel, AccountPreferencesModel, FieldsView,
LearnerProfileFieldsView, LearnerProfileView, AccountSettingsFieldViews, MessageBannerView) {
LearnerProfileFieldsView, LearnerProfileView, BadgeModel, PagingCollection, BadgeListContainer,
AccountSettingsFieldViews, MessageBannerView) {
return function (options) {
......@@ -121,6 +125,15 @@
})
];
var badgeCollection = new PagingCollection();
badgeCollection.url = options.badges_api_url;
var badgeListContainer = new BadgeListContainer({
'attributes': {'class': 'badge-set-display'},
'collection': badgeCollection,
'find_courses_url': options.find_courses_url
});
var learnerProfileView = new LearnerProfileView({
el: learnerProfileElement,
ownProfile: options.own_profile,
......@@ -131,7 +144,8 @@
profileImageFieldView: profileImageFieldView,
usernameFieldView: usernameFieldView,
sectionOneFieldViews: sectionOneFieldViews,
sectionTwoFieldViews: sectionTwoFieldViews
sectionTwoFieldViews: sectionTwoFieldViews,
badgeListContainer: badgeListContainer
});
var getProfileVisibility = function() {
......@@ -164,7 +178,8 @@
return {
accountSettingsModel: accountSettingsModel,
accountPreferencesModel: accountPreferencesModel,
learnerProfileView: learnerProfileView
learnerProfileView: learnerProfileView,
badgeListContainer: badgeListContainer
};
};
});
......
;(function (define, undefined) {
'use strict';
define([
'gettext', 'jquery', 'underscore', 'backbone', 'text!templates/student_profile/learner_profile.underscore'],
function (gettext, $, _, Backbone, learnerProfileTemplate) {
'gettext', 'jquery', 'underscore', 'backbone',
'common/js/components/views/tabbed_view',
'js/student_profile/views/section_two_tab',
'text!templates/student_profile/learner_profile.underscore'],
function (gettext, $, _, Backbone, TabbedView, SectionTwoTab, learnerProfileTemplate) {
var LearnerProfileView = Backbone.View.extend({
initialize: function () {
_.bindAll(this, 'showFullProfile', 'render', 'renderFields', 'showLoadingError');
this.listenTo(this.options.preferencesModel, "change:" + 'account_privacy', this.render);
var Router = Backbone.Router.extend({
routes: {":about_me": "loadTab", ":accomplishments": "loadTab"}
});
this.router = new Router();
this.firstRender = true;
},
template: _.template(learnerProfileTemplate),
showFullProfile: function () {
var isAboveMinimumAge = this.options.accountSettingsModel.isAboveMinimumAge();
......@@ -20,13 +31,71 @@
}
},
setActiveTab: function(tab) {
// This tab may not actually exist.
if (this.tabbedView.getTabMeta(tab).tab) {
this.tabbedView.setActiveTab(tab);
}
},
render: function () {
this.$el.html(_.template(learnerProfileTemplate)({
username: this.options.accountSettingsModel.get('username'),
ownProfile: this.options.ownProfile,
showFullProfile: this.showFullProfile()
var self = this;
this.sectionTwoView = new SectionTwoTab({
viewList: this.options.sectionTwoFieldViews,
showFullProfile: this.showFullProfile,
ownProfile: this.options.ownProfile
});
var tabs = [
{view: this.sectionTwoView, title: gettext("About Me"), url: "about_me"}
];
this.$el.html(this.template({
username: self.options.accountSettingsModel.get('username'),
ownProfile: self.options.ownProfile,
showFullProfile: self.showFullProfile()
}));
this.renderFields();
if (this.showFullProfile() && (this.options.accountSettingsModel.get('accomplishments_shared'))) {
tabs.push({
view: this.options.badgeListContainer,
title: gettext("Accomplishments"),
url: "accomplishments"
});
this.options.badgeListContainer.collection.fetch().done(function () {
self.options.badgeListContainer.render();
});
}
this.tabbedView = new TabbedView({
tabs: tabs,
router: this.router,
viewLabel: gettext("Profile")
});
this.tabbedView.render();
if (tabs.length === 1) {
// If the tab is unambiguous, don't display the tab interface.
this.tabbedView.$el.find('.page-content-nav').hide();
}
this.$el.find('.account-settings-container').append(this.tabbedView.el);
if (this.firstRender) {
this.router.on("route:loadTab", _.bind(this.setActiveTab, this));
Backbone.history.start();
this.firstRender = false;
// Load from history.
this.router.navigate((Backbone.history.getFragment() || 'about_me'), {trigger: true});
} else {
// Restart the router so the tab will be brought up anew.
Backbone.history.stop();
Backbone.history.start();
}
return this;
},
......@@ -54,9 +123,6 @@
view.$('.profile-section-one-fields').append(fieldView.render().el);
});
_.each(this.options.sectionTwoFieldViews, function (fieldView) {
view.$('.profile-section-two-fields').append(fieldView.render().el);
});
}
},
......
;(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:
- templates/verify_student
- templates/file-upload.underscore
- templates/components/header
- templates/components/tabbed
- templates/components/card
- templates/financial-assistance/
- js/fixtures/edxnotes
......
......@@ -15,6 +15,7 @@
// base - elements
@import 'elements/typography';
@import 'elements/controls';
@import 'elements/navigation';
@import 'elements/pagination';
@import 'elements/creative-commons';
@import 'elements/program-card';
......
......@@ -115,3 +115,39 @@
@include right($baseline*2.50);
}
}
%button-reset {
box-shadow: none;
border: none;
border-radius: 0;
background: transparent none;
font-family: inherit;
font-size: inherit;
font-weight: inherit;
}
%page-content-nav {
margin-bottom: $baseline;
border-bottom: 3px solid $gray-l5;
.nav-item {
@extend %button-reset;
display: inline-block;
margin-bottom: -3px; // to match the border
border-bottom: 3px solid $gray-l5;
padding: ($baseline*.75);
color: $gray-d2;
&.is-active {
border-bottom: 3px solid $gray-d2;
color: $gray-d2;
}
// STATE: hover and focus
&:hover,
&:focus {
border-bottom: 3px solid $link-color;
color: $link-color;
}
}
}
......@@ -189,7 +189,7 @@
.u-field-username {
input[type="text"] {
font-weight: 600;
font-weight: 600;
}
.u-field-value {
......@@ -231,6 +231,7 @@
}
.wrapper-profile-section-two {
padding-top: 1em;
width: flex-grid(8, 12);
}
......@@ -259,9 +260,9 @@
width: 100%;
textarea {
width: 100%;
background-color: transparent;
white-space: pre-line;
width: 100%;
background-color: transparent;
white-space: pre-line;
}
a {
......@@ -278,10 +279,10 @@
padding: $baseline;
border: 2px dashed $gray-l3;
i {
font-size: 12px;
@include padding-right(5px);
vertical-align: middle;
color: $gray;
font-size: 12px;
@include padding-right(5px);
vertical-align: middle;
color: $gray;
}
.u-field-title {
width: 100%;
......@@ -300,8 +301,75 @@
border: 2px dashed $link-color;
.u-field-title,
i {
color: $link-color;
color: $link-color;
}
}
}
.badge-paging-header {
padding-top: $baseline;
}
.page-content-nav {
@extend %page-content-nav;
}
.badge-set-display {
@extend .container;
padding: 0 0;
.badge-display {
width: 50%;
display: inline-block;
vertical-align: top;
padding: 2em 0;
.badge-image-container {
padding-right: $baseline;
margin-left: 1em;
width: 20%;
vertical-align: top;
display: inline-block;
img.badge {
width: 100%;
}
.accomplishment-placeholder {
border: 4px dotted $gray-l4;
border-radius: 50%;
display: block;
width: 100%;
padding-bottom: 100%;
}
}
.badge-details {
@extend %t-copy-sub1;
@extend %t-regular;
max-width: 70%;
display: inline-block;
color: $gray-d1;
.badge-name {
@extend %t-strong;
@extend %t-copy-base;
color: $gray-d3;
}
.badge-description {
padding-bottom: $baseline;
line-height: 1.5em;
}
.badge-date-stamp{
@extend %t-copy-sub1;
}
.find-button-container {
border: 1px solid $blue-l1;
padding: ($baseline / 2) $baseline ($baseline / 2) $baseline;
display: inline-block;
border-radius: 5px;
font-weight: bold;
color: $blue-s3;
}
}
}
.badge-placeholder {
background-color: $gray-l7;
box-shadow: inset 0 0 4px 0 $gray-l4;
}
}
}
......@@ -19,17 +19,6 @@
color: inherit;
}
%button-reset {
box-shadow: none;
border: none;
border-radius: 0;
background-image: none;
background-color: transparent;
font-family: inherit;
font-size: inherit;
font-weight: inherit;
}
// layout
.page-header {
@include clearfix();
......@@ -147,29 +136,7 @@
}
.page-content-nav {
margin-bottom: $baseline;
border-bottom: 3px solid $gray-l5;
.nav-item {
@extend %button-reset;
display: inline-block;
margin-bottom: -3px; // to match the border
border-bottom: 3px solid $gray-l5;
padding: ($baseline*.75);
color: $gray-d2;
&.is-active {
border-bottom: 3px solid $gray-d2;
color: $gray-d2;
}
// STATE: hover and focus
&:hover,
&:focus {
border-bottom: 3px solid $link-color;
color: $link-color;
}
}
@extend %page-content-nav;
}
.intro {
......
<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="wrapper-profile-field-account-privacy"></div>
<div class="wrapper-profile-sections account-settings-container">
<div class="wrapper-profile-section-one">
<div class="profile-image-field">
</div>
<div class="profile-section-one-fields">
</div>
</div>
<div class="ui-loading-error is-hidden">
<i class="fa fa-exclamation-triangle message-error" aria-hidden=true></i>
<span class="copy"><%- gettext("An error occurred. Please reload the page.") %></span>
</div>
<div class="wrapper-profile-section-two">
<div class="profile-section-two-fields">
<% if (!showFullProfile) { %>
<% if(ownProfile) { %>
<span class="profile-private--message" tabindex="0"><%- gettext("You are currently sharing a limited profile.") %></span>
<% } else { %>
<span class="profile-private--message" tabindex="0"><%- gettext("This edX learner is currently sharing a limited profile.") %></span>
<% } %>
<% } %>
</div>
<span class="copy"><%- gettext("An error occurred. Try loading the page again.") %></span>
</div>
</div>
</div>
<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"]:
if settings.FEATURES["ENABLE_OPENBADGES"]:
urlpatterns += (
url(r'^api/badges/v1/', include('badges.api.urls')),
url(r'^api/badges/v1/', include('badges.api.urls', app_name="badges", namespace="badges_api")),
)
js_info_dict = {
......
......@@ -24,19 +24,21 @@ from ..errors import (
)
from ..forms import PasswordResetFormNoActive
from ..helpers import intercept_errors
from ..models import UserPreference
from . import (
ACCOUNT_VISIBILITY_PREF_KEY, PRIVATE_VISIBILITY,
EMAIL_MIN_LENGTH, EMAIL_MAX_LENGTH, PASSWORD_MIN_LENGTH, PASSWORD_MAX_LENGTH,
USERNAME_MIN_LENGTH, USERNAME_MAX_LENGTH
)
from .serializers import (
AccountLegacyProfileSerializer, AccountUserSerializer,
UserReadOnlySerializer
UserReadOnlySerializer, _visible_fields # pylint: disable=invalid-name
)
# Public access point for this function.
visible_fields = _visible_fields
@intercept_errors(UserAPIInternalError, ignore_errors=[UserAPIRequestError])
def get_account_settings(request, username=None, configuration=None, view=None):
"""Returns account information for a user serialized as JSON.
......
......@@ -5,15 +5,15 @@ from rest_framework import serializers
from django.contrib.auth.models import User
from django.conf import settings
from django.core.urlresolvers import reverse
from openedx.core.djangoapps.user_api.accounts import NAME_MIN_LENGTH
from openedx.core.djangoapps.user_api.serializers import ReadOnlyFieldsSerializerMixin
from student.models import UserProfile, LanguageProficiency
from ..models import UserPreference
from .image_helpers import get_profile_image_urls_for_user
from . import (
ACCOUNT_VISIBILITY_PREF_KEY, ALL_USERS_VISIBILITY, PRIVATE_VISIBILITY,
NAME_MIN_LENGTH, ACCOUNT_VISIBILITY_PREF_KEY, PRIVATE_VISIBILITY,
ALL_USERS_VISIBILITY,
)
from openedx.core.djangoapps.user_api.models import UserPreference
from openedx.core.djangoapps.user_api.serializers import ReadOnlyFieldsSerializerMixin
from student.models import UserProfile, LanguageProficiency
from .image_helpers import get_profile_image_urls_for_user
PROFILE_IMAGE_KEY_PREFIX = 'image_url'
......@@ -52,7 +52,7 @@ class UserReadOnlySerializer(serializers.Serializer):
self.configuration = settings.ACCOUNT_VISIBILITY_CONFIGURATION
# Don't pass the 'custom_fields' arg up to the superclass
self.custom_fields = kwargs.pop('custom_fields', None)
self.custom_fields = kwargs.pop('custom_fields', [])
super(UserReadOnlySerializer, self).__init__(*args, **kwargs)
......@@ -63,6 +63,7 @@ class UserReadOnlySerializer(serializers.Serializer):
:return: Dict serialized account
"""
profile = user.profile
accomplishments_shared = settings.FEATURES.get('ENABLE_OPENBADGES') or False
data = {
"username": user.username,
......@@ -95,42 +96,19 @@ class UserReadOnlySerializer(serializers.Serializer):
"level_of_education": AccountLegacyProfileSerializer.convert_empty_to_None(profile.level_of_education),
"mailing_address": profile.mailing_address,
"requires_parental_consent": profile.requires_parental_consent(),
"account_privacy": self._get_profile_visibility(profile, user),
"accomplishments_shared": accomplishments_shared,
"account_privacy": get_profile_visibility(profile, user, self.configuration),
}
return self._filter_fields(
self._visible_fields(profile, user),
data
)
def _visible_fields(self, user_profile, user):
"""
Return what fields should be visible based on user settings
:param user_profile: User profile object
:param user: User object
:return: whitelist List of fields to be shown
"""
if self.custom_fields:
return self.custom_fields
profile_visibility = self._get_profile_visibility(user_profile, user)
if profile_visibility == ALL_USERS_VISIBILITY:
return self.configuration.get('shareable_fields')
fields = self.custom_fields
else:
return self.configuration.get('public_fields')
def _get_profile_visibility(self, user_profile, user):
"""Returns the visibility level for the specified user profile."""
if user_profile.requires_parental_consent():
return PRIVATE_VISIBILITY
fields = _visible_fields(profile, user, self.configuration)
# Calling UserPreference directly because the requesting user may be different from existing_user
# (and does not have to be is_staff).
profile_privacy = UserPreference.get_value(user, ACCOUNT_VISIBILITY_PREF_KEY)
return profile_privacy if profile_privacy else self.configuration.get('default_visibility')
return self._filter_fields(
fields,
data
)
def _filter_fields(self, field_whitelist, serialized_account):
"""
......@@ -260,3 +238,37 @@ class AccountLegacyProfileSerializer(serializers.HyperlinkedModelSerializer, Rea
])
return instance
def get_profile_visibility(user_profile, user, configuration=None):
"""Returns the visibility level for the specified user profile."""
if user_profile.requires_parental_consent():
return PRIVATE_VISIBILITY
if not configuration:
configuration = settings.ACCOUNT_VISIBILITY_CONFIGURATION
# Calling UserPreference directly because the requesting user may be different from existing_user
# (and does not have to be is_staff).
profile_privacy = UserPreference.get_value(user, ACCOUNT_VISIBILITY_PREF_KEY)
return profile_privacy if profile_privacy else configuration.get('default_visibility')
def _visible_fields(user_profile, user, configuration=None):
"""
Return what fields should be visible based on user settings
:param user_profile: User profile object
:param user: User object
:param configuration: A visibility configuration dictionary.
:return: whitelist List of fields to be shown
"""
if not configuration:
configuration = settings.ACCOUNT_VISIBILITY_CONFIGURATION
profile_visibility = get_profile_visibility(user_profile, user, configuration)
if profile_visibility == ALL_USERS_VISIBILITY:
return configuration.get('shareable_fields')
else:
return configuration.get('public_fields')
......@@ -87,7 +87,7 @@ class TestAccountApi(UserSettingsEventTestMixin, TestCase):
account_settings = get_account_settings(
self.default_request,
self.different_user.username,
configuration=config
configuration=config,
)
self.assertEqual(self.different_user.email, account_settings["email"])
......@@ -282,6 +282,7 @@ class AccountSettingsOnCreationTest(TestCase):
'requires_parental_consent': True,
'language_proficiencies': [],
'account_privacy': PRIVATE_VISIBILITY,
'accomplishments_shared': False,
})
......
......@@ -8,6 +8,7 @@ import datetime
import ddt
import hashlib
import json
from mock import patch
from nose.plugins.attrib import attr
from pytz import UTC
......@@ -155,12 +156,12 @@ class TestAccountAPI(UserAPITestCase):
}
)
def _verify_full_shareable_account_response(self, response, account_privacy=None):
def _verify_full_shareable_account_response(self, response, account_privacy=None, badges_enabled=False):
"""
Verify that the shareable fields from the account are returned
"""
data = response.data
self.assertEqual(7, len(data))
self.assertEqual(8, len(data))
self.assertEqual(self.user.username, data["username"])
self.assertEqual("US", data["country"])
self._verify_profile_image_data(data, True)
......@@ -168,6 +169,7 @@ class TestAccountAPI(UserAPITestCase):
self.assertEqual([{"code": "en"}], data["language_proficiencies"])
self.assertEqual("Tired mother of twins", data["bio"])
self.assertEqual(account_privacy, data["account_privacy"])
self.assertEqual(badges_enabled, data['accomplishments_shared'])
def _verify_private_account_response(self, response, requires_parental_consent=False, account_privacy=None):
"""
......@@ -184,7 +186,7 @@ class TestAccountAPI(UserAPITestCase):
Verify that all account fields are returned (even those that are not shareable).
"""
data = response.data
self.assertEqual(16, len(data))
self.assertEqual(17, len(data))
self.assertEqual(self.user.username, data["username"])
self.assertEqual(self.user.first_name + " " + self.user.last_name, data["name"])
self.assertEqual("US", data["country"])
......@@ -261,6 +263,7 @@ class TestAccountAPI(UserAPITestCase):
response = self.send_get(self.different_client)
self._verify_private_account_response(response, account_privacy=PRIVATE_VISIBILITY)
@patch.dict(settings.FEATURES, {'ENABLE_OPENBADGES': True})
@ddt.data(
("client", "user", PRIVATE_VISIBILITY),
("different_client", "different_user", PRIVATE_VISIBILITY),
......@@ -281,7 +284,7 @@ class TestAccountAPI(UserAPITestCase):
if preference_visibility == PRIVATE_VISIBILITY:
self._verify_private_account_response(response, account_privacy=PRIVATE_VISIBILITY)
else:
self._verify_full_shareable_account_response(response, ALL_USERS_VISIBILITY)
self._verify_full_shareable_account_response(response, ALL_USERS_VISIBILITY, badges_enabled=True)
client = self.login_client(api_client, requesting_username)
......@@ -311,7 +314,7 @@ class TestAccountAPI(UserAPITestCase):
with self.assertNumQueries(9):
response = self.send_get(self.client)
data = response.data
self.assertEqual(16, len(data))
self.assertEqual(17, len(data))
self.assertEqual(self.user.username, data["username"])
self.assertEqual(self.user.first_name + " " + self.user.last_name, data["name"])
for empty_field in ("year_of_birth", "level_of_education", "mailing_address", "bio"):
......@@ -326,6 +329,8 @@ class TestAccountAPI(UserAPITestCase):
self.assertTrue(data["requires_parental_consent"])
self.assertEqual([], data["language_proficiencies"])
self.assertEqual(PRIVATE_VISIBILITY, data["account_privacy"])
# Badges aren't on by default, so should not be present.
self.assertEqual(False, data["accomplishments_shared"])
self.client.login(username=self.user.username, password=self.test_password)
verify_get_own_information()
......@@ -693,7 +698,7 @@ class TestAccountAPI(UserAPITestCase):
response = self.send_get(client)
if has_full_access:
data = response.data
self.assertEqual(16, len(data))
self.assertEqual(17, len(data))
self.assertEqual(self.user.username, data["username"])
self.assertEqual(self.user.first_name + " " + self.user.last_name, data["name"])
self.assertEqual(self.user.email, data["email"])
......
......@@ -5,8 +5,9 @@ For more information, see:
https://openedx.atlassian.net/wiki/display/TNL/User+API
"""
from django.db import transaction
from rest_framework import status, permissions
from rest_framework_jwt.authentication import JSONWebTokenAuthentication
from rest_framework import permissions
from rest_framework import status
from rest_framework.response import Response
from rest_framework.views import APIView
......@@ -14,9 +15,9 @@ from openedx.core.lib.api.authentication import (
SessionAuthenticationAllowInactiveUser,
OAuth2AuthenticationAllowInactiveUser,
)
from ..errors import UserNotFound, UserNotAuthorized, AccountUpdateError, AccountValidationError
from openedx.core.lib.api.parsers import MergePatchParser
from .api import get_account_settings, update_account_settings
from ..errors import UserNotFound, UserNotAuthorized, AccountUpdateError, AccountValidationError
class AccountView(APIView):
......@@ -97,6 +98,8 @@ class AccountView(APIView):
* year_of_birth: The year the user was born, as an integer, or null.
* account_privacy: The user's setting for sharing her personal
profile. Possible values are "all_users" or "private".
* accomplishments_shared: Signals whether badges are enabled on the
platform and should be fetched.
For all text fields, plain text instead of HTML is supported. The
data is stored exactly as specified. Clients must HTML escape
......@@ -148,7 +151,8 @@ class AccountView(APIView):
GET /api/user/v1/accounts/{username}/
"""
try:
account_settings = get_account_settings(request, username, view=request.query_params.get('view'))
account_settings = get_account_settings(
request, username, view=request.query_params.get('view'))
except UserNotFound:
return Response(status=status.HTTP_403_FORBIDDEN if request.user.is_staff else status.HTTP_404_NOT_FOUND)
......
"""
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):
by any subclass of Django Rest Framework's generic API views.
"""
page_size_query_param = "page_size"
max_page_size = 100
def get_paginated_response(self, data):
"""
......@@ -25,6 +26,8 @@ class DefaultPagination(pagination.PageNumberPagination):
'previous': self.get_previous_link(),
'count': self.page.paginator.count,
'num_pages': self.page.paginator.num_pages,
'current_page': self.page.number,
'start': (self.page.number - 1) * self.get_page_size(self.request),
'results': data
})
......
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