Commit 9f88427f by Harry Rein

Add social links to learner profile.

LEARNER-1859

Added fields to add social links to the user account settings file.
Added icons to the user profile when these links are set, only shown
when users show their entire profile. Added jasmine tests for account
settings and learner profile pages. Added python unit tests to test
validation on the user account.
parent d0c7a532
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('student', '0011_course_key_field_to_foreign_key'),
]
operations = [
migrations.CreateModel(
name='SocialLink',
fields=[
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
('platform', models.CharField(max_length=30)),
('social_link', models.CharField(max_length=100, blank=True)),
('user_profile', models.ForeignKey(related_name='social_links', to='student.UserProfile')),
],
),
]
......@@ -2374,6 +2374,21 @@ class LanguageProficiency(models.Model):
)
class SocialLink(models.Model): # pylint: disable=model-missing-unicode
"""
Represents a URL connecting a particular social platform to a user's social profile.
The platforms are listed in the lms/common.py file under SOCIAL_PLATFORMS.
Each entry has a display name, a url_stub that describes a required
component of the stored URL and an example of a valid URL.
The stored social_link value must adhere to the form 'https://www.[url_stub][username]'.
"""
user_profile = models.ForeignKey(UserProfile, db_index=True, related_name='social_links')
platform = models.CharField(max_length=30)
social_link = models.CharField(max_length=100, blank=True)
class CourseEnrollmentAttribute(models.Model):
"""
Provide additional information about the user's enrollment.
......
......@@ -124,6 +124,7 @@ class AccountSettingsPageTest(AccountSettingsTestMixin, AcceptanceTest):
"""
super(AccountSettingsPageTest, self).setUp()
self.full_name = XSS_INJECTION
self.social_link = ''
self.username, self.user_id = self.log_in_as_unique_user(full_name=self.full_name)
self.visit_account_settings_page()
......@@ -177,6 +178,14 @@ class AccountSettingsPageTest(AccountSettingsTestMixin, AcceptanceTest):
'Year of Birth',
'Preferred Language',
]
},
{
'title': 'Social Media Links',
'fields': [
'Twitter Link',
'Facebook Link',
'LinkedIn Link',
]
}
]
......@@ -463,6 +472,18 @@ class AccountSettingsPageTest(AccountSettingsTestMixin, AcceptanceTest):
actual_events
)
def test_social_links_field(self):
"""
Test behaviour of one of the social media links field.
"""
self._test_text_field(
u'social_links',
u'Twitter Link',
self.social_link,
u'www.google.com/invalidlink',
[u'https://www.twitter.com/edX', self.social_link],
)
def test_linked_accounts(self):
"""
Test that fields for third party auth providers exist.
......
......@@ -2931,6 +2931,7 @@ ACCOUNT_VISIBILITY_CONFIGURATION = {
'date_joined',
'language_proficiencies',
'bio',
'social_links',
'account_privacy',
# Not an actual field, but used to signal whether badges should be public.
'accomplishments_shared',
......@@ -2953,6 +2954,7 @@ ACCOUNT_VISIBILITY_CONFIGURATION = {
"date_joined",
"profile_image",
"language_proficiencies",
"social_links",
"name",
"gender",
"goals",
......@@ -2965,6 +2967,32 @@ ACCOUNT_VISIBILITY_CONFIGURATION = {
]
}
# The current list of social platforms to be shown to the user.
#
# url_stub represents the host URL, it must end with a forward
# slash and represent the profile at https://www.[url_stub][username]
#
# The example will be used as a placeholder in the social link
# input field as well as in some messaging describing an example of a
# valid link.
SOCIAL_PLATFORMS = {
'facebook': {
'display_name': 'Facebook',
'url_stub': 'facebook.com/',
'example': 'https://www.facebook.com/username'
},
'twitter': {
'display_name': 'Twitter',
'url_stub': 'twitter.com/',
'example': 'https://www.twitter.com/username'
},
'linkedin': {
'display_name': 'LinkedIn',
'url_stub': 'linkedin.com/in/',
'example': 'www.linkedin.com/in/username'
}
}
# E-Commerce API Configuration
ECOMMERCE_PUBLIC_URL_ROOT = None
ECOMMERCE_API_URL = None
......
define(['backbone',
'jquery',
'underscore',
'edx-ui-toolkit/js/utils/spec-helpers/ajax-helpers',
'common/js/spec_helpers/template_helpers',
'js/views/fields',
'js/spec/views/fields_helpers',
'js/spec/student_account/account_settings_fields_helpers',
'js/student_account/views/account_settings_fields',
'js/student_account/models/user_account_model',
'string_utils'],
function(Backbone, $, _, AjaxHelpers, TemplateHelpers, FieldViews, FieldViewsSpecHelpers,
'jquery',
'underscore',
'edx-ui-toolkit/js/utils/spec-helpers/ajax-helpers',
'common/js/spec_helpers/template_helpers',
'js/student_account/models/user_account_model',
'js/views/fields',
'js/spec/views/fields_helpers',
'js/spec/student_account/account_settings_fields_helpers',
'js/student_account/views/account_settings_fields',
'js/student_account/models/user_account_model',
'string_utils'],
function(Backbone, $, _, AjaxHelpers, TemplateHelpers, UserAccountModel, FieldViews, FieldViewsSpecHelpers,
AccountSettingsFieldViewSpecHelpers, AccountSettingsFieldViews) {
'use strict';
describe('edx.AccountSettingsFieldViews', function() {
var requests,
timerCallback;
timerCallback, // eslint-disable-line no-unused-vars
data;
beforeEach(function() {
timerCallback = jasmine.createSpy('timerCallback');
......@@ -40,7 +42,7 @@ define(['backbone',
view.$('.u-field-value > button').click();
expect(view.$('.u-field-value > button').is(':disabled')).toBe(true);
AjaxHelpers.expectRequest(requests, 'POST', '/password_reset', 'email=legolas%40woodland.middlearth');
AjaxHelpers.respondWithJson(requests, {'success': 'true'});
AjaxHelpers.respondWithJson(requests, {success: 'true'});
FieldViewsSpecHelpers.expectMessageContains(
view,
"We've sent a message to legolas@woodland.middlearth. " +
......@@ -130,7 +132,7 @@ define(['backbone',
var view = new AccountSettingsFieldViews.LanguagePreferenceFieldView(fieldData).render();
var data = {'language': FieldViewsSpecHelpers.SELECT_OPTIONS[2][0]};
data = {language: FieldViewsSpecHelpers.SELECT_OPTIONS[2][0]};
view.$(selector).val(data[fieldData.valueAttribute]).change();
view.$(selector).focusout();
FieldViewsSpecHelpers.expectAjaxRequestWithData(requests, data);
......@@ -145,7 +147,7 @@ define(['backbone',
AjaxHelpers.respondWithNoContent(requests);
FieldViewsSpecHelpers.expectMessageContains(view, 'Your changes have been saved.');
data = {'language': FieldViewsSpecHelpers.SELECT_OPTIONS[1][0]};
data = {language: FieldViewsSpecHelpers.SELECT_OPTIONS[1][0]};
view.$(selector).val(data[fieldData.valueAttribute]).change();
view.$(selector).focusout();
FieldViewsSpecHelpers.expectAjaxRequestWithData(requests, data);
......@@ -173,13 +175,13 @@ define(['backbone',
options: FieldViewsSpecHelpers.SELECT_OPTIONS,
persistChanges: true
});
fieldData.model.set({'language_proficiencies': [{'code': FieldViewsSpecHelpers.SELECT_OPTIONS[0][0]}]});
fieldData.model.set({language_proficiencies: [{code: FieldViewsSpecHelpers.SELECT_OPTIONS[0][0]}]});
var view = new AccountSettingsFieldViews.LanguageProficienciesFieldView(fieldData).render();
expect(view.modelValue()).toBe(FieldViewsSpecHelpers.SELECT_OPTIONS[0][0]);
var data = {'language_proficiencies': [{'code': FieldViewsSpecHelpers.SELECT_OPTIONS[1][0]}]};
data = {language_proficiencies: [{code: FieldViewsSpecHelpers.SELECT_OPTIONS[1][0]}]};
view.$(selector).val(FieldViewsSpecHelpers.SELECT_OPTIONS[1][0]).change();
view.$(selector).focusout();
FieldViewsSpecHelpers.expectAjaxRequestWithData(requests, data);
......
define(['backbone',
'jquery',
'underscore',
'edx-ui-toolkit/js/utils/spec-helpers/ajax-helpers',
'common/js/spec_helpers/template_helpers',
'js/spec/student_account/helpers',
'js/views/fields',
'js/student_account/models/user_account_model',
'js/student_account/views/account_settings_view'
],
'jquery',
'underscore',
'edx-ui-toolkit/js/utils/spec-helpers/ajax-helpers',
'common/js/spec_helpers/template_helpers',
'js/spec/student_account/helpers',
'js/views/fields',
'js/student_account/models/user_account_model',
'js/student_account/views/account_settings_view'
],
function(Backbone, $, _, AjaxHelpers, TemplateHelpers, Helpers, FieldViews, UserAccountModel,
AccountSettingsView) {
'use strict';
......
......@@ -65,6 +65,23 @@ define(['underscore'], function(_) {
};
var IMAGE_MAX_BYTES = 1024 * 1024;
var IMAGE_MIN_BYTES = 100;
var SOCIAL_PLATFORMS = {
facebook: {
display_name: 'Facebook',
url_stub: 'facebook.com/',
example: 'https://www.facebook.com/username'
},
twitter: {
display_name: 'Twitter',
url_stub: 'twitter.com/',
example: 'https://www.twitter.com/username'
},
linkedin: {
display_name: 'LinkedIn',
url_stub: 'linkedin.com/in/',
example: 'https://www.linkedin.com/in/username'
}
};
var DEFAULT_ACCOUNT_SETTINGS_DATA = {
username: 'student',
name: 'Student',
......@@ -77,6 +94,7 @@ define(['underscore'], function(_) {
language: 'en-US',
date_joined: 'December 17, 1995 03:24:00',
bio: 'About the student',
social_links: [{platform: 'facebook', social_link: 'https://www.facebook.com/edX'}],
language_proficiencies: [{code: '1'}],
profile_image: PROFILE_IMAGE,
accomplishments_shared: false
......@@ -170,6 +188,7 @@ define(['underscore'], function(_) {
AUTH_DATA: AUTH_DATA,
IMAGE_MAX_BYTES: IMAGE_MAX_BYTES,
IMAGE_MIN_BYTES: IMAGE_MIN_BYTES,
SOCIAL_PLATFORMS: SOCIAL_PLATFORMS,
createAccountSettingsData: createAccountSettingsData,
createUserPreferencesData: createUserPreferencesData,
expectLoadingIndicatorIsVisible: expectLoadingIndicatorIsVisible,
......
......@@ -19,6 +19,7 @@
mailing_address: '',
year_of_birth: null,
bio: null,
social_links: [],
language_proficiencies: [],
requires_parental_consent: true,
profile_image: null,
......
......@@ -19,14 +19,15 @@
accountUserId,
platformName,
contactEmail,
allowEmailChange
allowEmailChange,
socialPlatforms
) {
var accountSettingsElement, userAccountModel, userPreferencesModel, aboutSectionsData,
var $accountSettingsElement, userAccountModel, userPreferencesModel, aboutSectionsData,
accountsSectionData, ordersSectionData, accountSettingsView, showAccountSettingsPage,
showLoadingError, orderNumber, getUserField, userFields, timeZoneDropdownField, countryDropdownField,
emailFieldView;
emailFieldView, socialFields, platformData;
accountSettingsElement = $('.wrapper-account-settings');
$accountSettingsElement = $('.wrapper-account-settings');
userAccountModel = new UserAccountModel();
userAccountModel.url = userAccountsApiUrl;
......@@ -64,7 +65,7 @@
aboutSectionsData = [
{
title: gettext('Basic Account Information'),
subtitle: gettext('These settings include basic information about your account. You can also specify additional information and see your linked social accounts on this page.'), // eslint-disable-line max-len
subtitle: gettext('These settings include basic information about your account.'),
fields: [
{
view: new AccountSettingsFieldViews.ReadonlyFieldView({
......@@ -191,6 +192,34 @@
}
];
// Add the social link fields
socialFields = {
title: gettext('Social Media Links'),
subtitle: gettext('Optionally, link your personal accounts to the social media icons on your edX profile.'), // eslint-disable-line max-len
fields: []
};
for (var socialPlatform in socialPlatforms) { // eslint-disable-line guard-for-in, no-restricted-syntax, vars-on-top, max-len
platformData = socialPlatforms[socialPlatform];
socialFields.fields.push(
{
view: new AccountSettingsFieldViews.SocialLinkTextFieldView({
model: userAccountModel,
title: gettext(platformData.display_name + ' Link'),
valueAttribute: 'social_links',
helpMessage: gettext(
'Enter your ' + platformData.display_name + ' username or the URL to your ' +
platformData.display_name + ' page. Delete the URL to remove the link.'
),
platform: socialPlatform,
persistChanges: true,
placeholder: platformData.example
})
}
);
}
aboutSectionsData.push(socialFields);
// set TimeZoneField to listen to CountryField
getUserField = function(list, search) {
return _.find(list, function(field) {
......@@ -266,7 +295,7 @@
accountSettingsView = new AccountSettingsView({
model: userAccountModel,
accountUserId: accountUserId,
el: accountSettingsElement,
el: $accountSettingsElement,
tabSections: {
aboutTabSections: aboutSectionsData,
accountsTabSections: accountsSectionData,
......
......@@ -224,6 +224,39 @@
}
}
}),
SocialLinkTextFieldView: FieldViews.TextFieldView.extend({
render: function() {
HtmlUtils.setHtml(this.$el, HtmlUtils.template(field_text_account_template)({
id: this.options.valueAttribute + '_' + this.options.platform,
title: this.options.title,
value: this.modelValue(),
message: this.options.helpMessage,
placeholder: this.options.placeholder || ''
}));
this.delegateEvents();
return this;
},
modelValue: function() {
var socialLinks = this.model.get(this.options.valueAttribute);
for (var i = 0; i < socialLinks.length; i++) { // eslint-disable-line vars-on-top
if (socialLinks[i].platform === this.options.platform) {
return socialLinks[i].social_link;
}
}
return null;
},
saveValue: function() {
var attributes, value;
if (this.persistChanges === true) {
attributes = {};
value = this.fieldValue() != null ? [{platform: this.options.platform,
social_link: this.fieldValue()}] : [];
attributes[this.options.valueAttribute] = value;
this.saveAttributes(attributes);
}
}
}),
AuthFieldView: FieldViews.LinkFieldView.extend({
fieldTemplate: field_social_link_template,
className: function() {
......
......@@ -367,7 +367,8 @@
id: this.options.valueAttribute,
title: this.options.title,
value: this.modelValue(),
message: this.helpMessage
message: this.helpMessage,
placeholder: this.options.placeholder || ''
}));
this.delegateEvents();
return this;
......
......@@ -195,6 +195,10 @@
.profile-header {
@include padding(0, $baseline*2, $baseline, $baseline*3);
@media (max-width: $learner-profile-container-flex) { // Switch to map-get($grid-breakpoints,md) for bootstrap
@include padding(0, $baseline*2, $baseline, $baseline*0.75);
}
.header {
@extend %t-title4;
@extend %t-ultrastrong;
......@@ -221,11 +225,34 @@
}
.profile-section-one-fields {
margin: 0 $baseline/2;
@include margin(0, $baseline/2, 0, $baseline*0.75);
.social-links {
font-size: 2rem;
padding-top: $baseline/4;
& > span {
color: $gray-l4;
}
a {
.fa-facebook-square {
color: $facebook-blue;
}
.fa-twitter-square {
color: $twitter-blue;
}
.fa-linkedin-square {
color: $linkedin-blue;
}
}
}
.u-field {
@extend %t-weight4;
@include padding(0, 0, 0, 3px);
padding: 0;
color: $base-font-color;
margin-top: $baseline/5;
......@@ -297,18 +324,19 @@
.wrapper-profile-section-container-two {
@include float(left);
width: calc(100% - 360px);
@include padding-left($baseline);
width: calc(100% - 380px);
max-width: $learner-profile-container-flex; // Switch to map-get($grid-breakpoints,md) for bootstrap
padding-left: $baseline;
@media (max-width: $learner-profile-container-flex) { // Switch to map-get($grid-breakpoints,md) for bootstrap
@include padding-left(0);
width: 100%;
margin-top: $baseline;
}
.u-field-textarea {
margin-bottom: ($baseline/2);
padding: 0 ($baseline*.75) ($baseline*.75) ($baseline/4);
@include padding(0, ($baseline*.75), ($baseline*.75), ($baseline/4));
.u-field-header {
position: relative;
......
......@@ -94,7 +94,7 @@
.account-settings-sections {
.section-header {
@extend %t-title6;
@extend %t-title5;
@extend %t-strong;
padding-top: ($baseline/2)*3;
color: $dark-gray1;
......@@ -102,14 +102,13 @@
.section {
background-color: $white;
margin-top: $baseline;
margin: $baseline 5% 0;
border-bottom: 4px solid $m-gray-l4;
.account-settings-header-subtitle {
font-size: em(18);
font-size: em(14);
line-height: normal;
color: $dark-gray;
padding-top: 20px;
padding-bottom: 10px;
}
......@@ -117,6 +116,7 @@
.u-field {
border-bottom: 2px solid $m-gray-l4;
padding: $baseline*0.75 0;
.field {
width: 30%;
......@@ -301,7 +301,8 @@
.u-field-message {
position: relative;
padding: 24px 0 0 ($baseline*5);
padding: $baseline*0.75 0 0 ($baseline*4);
width: 60%;
.u-field-message-notification {
position: absolute;
......
<div class="u-field-value field">
<label class="u-field-title field-label" for="field-input-<%- id %>"><%- title %></label>
<input class="field-input input-text" type="text" id="field-input-<%- id %>" title="Input field for <%- id %>" aria-describedby="u-field-message-help-<%- id %>" name="input" value="<%- value %>" />
<input class="field-input input-text" placeholder="<%- placeholder %>" type="text" id="field-input-<%- id %>" title="Input field for <%- id %>" aria-describedby="u-field-message-help-<%- id %>" name="input" value="<%- value %>" />
</div>
<span class="u-field-message" id="u-field-message-<%- id %>">
<span class="u-field-message-notification" aria-live="polite"></span>
......
......@@ -37,7 +37,8 @@ from openedx.core.djangolib.js_utils import dump_js_escaped_json, js_escaped_str
authData = ${ auth | n, dump_js_escaped_json },
platformName = '${ static.get_platform_name() | n, js_escaped_string }',
contactEmail = '${ static.get_contact_email_address() | n, js_escaped_string }',
allowEmailChange = ${ bool(settings.FEATURES['ALLOW_EMAIL_ADDRESS_CHANGE']) | n, dump_js_escaped_json };
allowEmailChange = ${ bool(settings.FEATURES['ALLOW_EMAIL_ADDRESS_CHANGE']) | n, dump_js_escaped_json },
socialPlatforms = ${ settings.SOCIAL_PLATFORMS | n, dump_js_escaped_json };
AccountSettingsFactory(
fieldsData,
......@@ -49,7 +50,8 @@ from openedx.core.djangolib.js_utils import dump_js_escaped_json, js_escaped_str
${ user.id | n, dump_js_escaped_json },
platformName,
contactEmail,
allowEmailChange
allowEmailChange,
socialPlatforms
);
</%static:require_module>
</%block>
......@@ -3,10 +3,10 @@
</h2>
<% _.each(sections, function(section) { %>
<div class="section">
<h3 class="section-header"><%- gettext(section.title) %></h3>
<% if (section.subtitle) { %>
<p class="account-settings-header-subtitle"><%- section.subtitle %></p>
<% } %>
<h3 class="section-header"><%- gettext(section.title) %></h3>
<div class="account-settings-section-body <%- tabName %>-section-body">
<div class="ui-loading-error is-hidden">
<span class="fa fa-exclamation-triangle message-error" aria-hidden="true"></span>
......
......@@ -11,7 +11,7 @@ from django.conf import settings
from django.core.validators import validate_email, ValidationError
from django.http import HttpResponseForbidden
from openedx.core.djangoapps.user_api.preferences.api import update_user_preferences
from openedx.core.djangoapps.user_api.errors import PreferenceValidationError
from openedx.core.djangoapps.user_api.errors import PreferenceValidationError, AccountValidationError
from student.models import User, UserProfile, Registration
from student import forms as student_forms
......@@ -216,7 +216,9 @@ def update_account_settings(requesting_user, update, username=None):
existing_user_profile.save()
except PreferenceValidationError as err:
raise errors.AccountValidationError(err.preference_errors)
raise AccountValidationError(err.preference_errors)
except AccountValidationError as err:
raise err
except Exception as err:
raise errors.AccountUpdateError(
u"Error thrown when saving account updates: '{}'".format(err.message)
......
......@@ -10,15 +10,17 @@ from django.core.exceptions import ObjectDoesNotExist
from django.core.urlresolvers import reverse
from lms.djangoapps.badges.utils import badges_enabled
from openedx.core.djangoapps.user_api import errors
from openedx.core.djangoapps.user_api.models import UserPreference
from openedx.core.djangoapps.user_api.serializers import ReadOnlyFieldsSerializerMixin
from student.models import UserProfile, LanguageProficiency, SocialLink
from . import (
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
from .utils import validate_social_link, format_social_link
PROFILE_IMAGE_KEY_PREFIX = 'image_url'
LOGGER = logging.getLogger(__name__)
......@@ -46,6 +48,15 @@ class LanguageProficiencySerializer(serializers.ModelSerializer):
return None
class SocialLinkSerializer(serializers.ModelSerializer):
"""
Class that serializes the SocialLink model for the UserProfile object.
"""
class Meta(object):
model = SocialLink
fields = ("platform", "social_link")
class UserReadOnlySerializer(serializers.Serializer):
"""
Class that serializes the User model and UserProfile model together.
......@@ -99,7 +110,8 @@ class UserReadOnlySerializer(serializers.Serializer):
"mailing_address": None,
"requires_parental_consent": None,
"accomplishments_shared": accomplishments_shared,
"account_privacy": self.configuration.get('default_visibility')
"account_privacy": self.configuration.get('default_visibility'),
"social_links": None,
}
if user_profile:
......@@ -122,7 +134,10 @@ class UserReadOnlySerializer(serializers.Serializer):
),
"mailing_address": user_profile.mailing_address,
"requires_parental_consent": user_profile.requires_parental_consent(),
"account_privacy": get_profile_visibility(user_profile, user, self.configuration)
"account_privacy": get_profile_visibility(user_profile, user, self.configuration),
"social_links": SocialLinkSerializer(
user_profile.social_links.all(), many=True
).data,
}
)
......@@ -168,11 +183,12 @@ class AccountLegacyProfileSerializer(serializers.HyperlinkedModelSerializer, Rea
profile_image = serializers.SerializerMethodField("_get_profile_image")
requires_parental_consent = serializers.SerializerMethodField()
language_proficiencies = LanguageProficiencySerializer(many=True, required=False)
social_links = SocialLinkSerializer(many=True, required=False)
class Meta(object):
model = UserProfile
fields = (
"name", "gender", "goals", "year_of_birth", "level_of_education", "country",
"name", "gender", "goals", "year_of_birth", "level_of_education", "country", "social_links",
"mailing_address", "bio", "profile_image", "requires_parental_consent", "language_proficiencies"
)
# Currently no read-only field, but keep this so view code doesn't need to know.
......@@ -192,7 +208,15 @@ class AccountLegacyProfileSerializer(serializers.HyperlinkedModelSerializer, Rea
language_proficiencies = [language for language in value]
unique_language_proficiencies = set(language["code"] for language in language_proficiencies)
if len(language_proficiencies) != len(unique_language_proficiencies):
raise serializers.ValidationError("The language_proficiencies field must consist of unique languages")
raise serializers.ValidationError("The language_proficiencies field must consist of unique languages.")
return value
def validate_social_links(self, value):
""" Enforce only one entry for a particular social platform. """
social_links = [social_link for social_link in value]
unique_social_links = set(social_link["platform"] for social_link in social_links)
if len(social_links) != len(unique_social_links):
raise serializers.ValidationError("The social_links field must consist of unique social platforms.")
return value
def transform_gender(self, user_profile, value): # pylint: disable=unused-argument
......@@ -244,20 +268,22 @@ class AccountLegacyProfileSerializer(serializers.HyperlinkedModelSerializer, Rea
def update(self, instance, validated_data):
"""
Update the profile, including nested fields.
Raises:
errors.AccountValidationError: the update was not attempted because validation errors were found with
the supplied update
"""
language_proficiencies = validated_data.pop("language_proficiencies", None)
# Update all fields on the user profile that are writeable,
# except for "language_proficiencies", which we'll update separately
update_fields = set(self.get_writeable_fields()) - set(["language_proficiencies"])
# except for "language_proficiencies" and "social_links", which we'll update separately
update_fields = set(self.get_writeable_fields()) - set(["language_proficiencies"]) - set(["social_links"])
for field_name in update_fields:
default = getattr(instance, field_name)
field_value = validated_data.get(field_name, default)
setattr(instance, field_name, field_value)
instance.save()
# Now update the related language proficiency
# Update the related language proficiency
if language_proficiencies is not None:
instance.language_proficiencies.all().delete()
instance.language_proficiencies.bulk_create([
......@@ -265,6 +291,39 @@ class AccountLegacyProfileSerializer(serializers.HyperlinkedModelSerializer, Rea
for language in language_proficiencies
])
# Update the user's social links
social_link_data = self._kwargs['data']['social_links'] if 'social_links' in self._kwargs['data'] else None
if social_link_data and len(social_link_data) > 0:
new_social_link = social_link_data[0]
current_social_links = list(instance.social_links.all())
instance.social_links.all().delete()
try:
# Add the new social link with correct formatting
validate_social_link(new_social_link['platform'], new_social_link['social_link'])
formatted_link = format_social_link(new_social_link['platform'], new_social_link['social_link'])
instance.social_links.bulk_create([
SocialLink(user_profile=instance, platform=new_social_link['platform'], social_link=formatted_link)
])
except ValueError as err:
# If we have encountered any validation errors, return them to the user.
raise errors.AccountValidationError({
'social_links': {
"developer_message": u"Error thrown from adding new social link: '{}'".format(err.message),
"user_message": err.message
}
})
# Add back old links unless overridden by new link
for current_social_link in current_social_links:
if current_social_link.platform != new_social_link['platform']:
instance.social_links.bulk_create([
SocialLink(user_profile=instance, platform=current_social_link.platform,
social_link=current_social_link.social_link)
])
instance.save()
return instance
......
......@@ -297,6 +297,7 @@ class AccountSettingsOnCreationTest(TestCase):
'mailing_address': None,
'year_of_birth': None,
'country': None,
'social_links': [],
'bio': None,
'profile_image': {
'has_image': False,
......
""" Unit tests for custom UserProfile properties. """
import ddt
from django.test import TestCase
from openedx.core.djangolib.testing.utils import skip_unless_lms
from ..utils import validate_social_link, format_social_link
@ddt.ddt
class UserAccountSettingsTest(TestCase):
"""Unit tests for setting Social Media Links."""
def setUp(self):
super(UserAccountSettingsTest, self).setUp()
def validate_social_link(self, social_platform, link):
"""
Helper method that returns True if the social link is valid, False if
the input link fails validation and will throw an error.
"""
try:
validate_social_link(social_platform, link)
except ValueError:
return False
return True
@ddt.data(
('facebook', 'www.facebook.com/edX', 'https://www.facebook.com/edX', True),
('facebook', 'facebook.com/edX/', 'https://www.facebook.com/edX', True),
('facebook', 'HTTP://facebook.com/edX/', 'https://www.facebook.com/edX', True),
('facebook', 'www.evilwebsite.com/123', None, False),
('twitter', 'https://www.twiter.com/edX/', None, False),
('twitter', 'https://www.twitter.com/edX/123s', None, False),
('twitter', 'twitter.com/edX', 'https://www.twitter.com/edX', True),
('twitter', 'twitter.com/edX?foo=bar', 'https://www.twitter.com/edX', True),
('linkedin', 'www.linkedin.com/harryrein', None, False),
('linkedin', 'www.linkedin.com/in/harryrein-1234', 'https://www.linkedin.com/in/harryrein-1234', True),
('linkedin', 'www.evilwebsite.com/123?www.linkedin.com/edX', None, False),
('linkedin', '', '', True),
('linkedin', None, None, False),
)
@ddt.unpack
@skip_unless_lms
def test_social_link_input(self, platform_name, link_input, formatted_link_expected, is_valid_expected):
"""
Verify that social links are correctly validated and formatted.
"""
self.assertEqual(is_valid_expected, self.validate_social_link(platform_name, link_input))
self.assertEqual(formatted_link_expected, format_social_link(platform_name, link_input))
......@@ -222,7 +222,7 @@ class TestAccountsAPI(CacheIsolationTestCase, UserAPITestCase):
Verify that the shareable fields from the account are returned
"""
data = response.data
self.assertEqual(9, len(data))
self.assertEqual(10, len(data))
self.assertEqual(self.user.username, data["username"])
self.assertEqual("US", data["country"])
self._verify_profile_image_data(data, True)
......@@ -247,7 +247,7 @@ class TestAccountsAPI(CacheIsolationTestCase, UserAPITestCase):
Verify that all account fields are returned (even those that are not shareable).
"""
data = response.data
self.assertEqual(17, len(data))
self.assertEqual(18, 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"])
......@@ -305,7 +305,7 @@ class TestAccountsAPI(CacheIsolationTestCase, UserAPITestCase):
"""
self.different_client.login(username=self.different_user.username, password=TEST_PASSWORD)
self.create_mock_profile(self.user)
with self.assertNumQueries(19):
with self.assertNumQueries(20):
response = self.send_get(self.different_client)
self._verify_full_shareable_account_response(response, account_privacy=ALL_USERS_VISIBILITY)
......@@ -320,7 +320,7 @@ class TestAccountsAPI(CacheIsolationTestCase, UserAPITestCase):
"""
self.different_client.login(username=self.different_user.username, password=TEST_PASSWORD)
self.create_mock_profile(self.user)
with self.assertNumQueries(19):
with self.assertNumQueries(20):
response = self.send_get(self.different_client)
self._verify_private_account_response(response, account_privacy=PRIVATE_VISIBILITY)
......@@ -376,7 +376,7 @@ class TestAccountsAPI(CacheIsolationTestCase, UserAPITestCase):
with self.assertNumQueries(queries):
response = self.send_get(self.client)
data = response.data
self.assertEqual(17, len(data))
self.assertEqual(18, 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"):
......@@ -395,12 +395,12 @@ class TestAccountsAPI(CacheIsolationTestCase, UserAPITestCase):
self.assertEqual(False, data["accomplishments_shared"])
self.client.login(username=self.user.username, password=TEST_PASSWORD)
verify_get_own_information(17)
verify_get_own_information(18)
# Now make sure that the user can get the same information, even if not active
self.user.is_active = False
self.user.save()
verify_get_own_information(11)
verify_get_own_information(12)
def test_get_account_empty_string(self):
"""
......@@ -414,7 +414,7 @@ class TestAccountsAPI(CacheIsolationTestCase, UserAPITestCase):
legacy_profile.save()
self.client.login(username=self.user.username, password=TEST_PASSWORD)
with self.assertNumQueries(17):
with self.assertNumQueries(18):
response = self.send_get(self.client)
for empty_field in ("level_of_education", "gender", "country", "bio"):
self.assertIsNone(response.data[empty_field])
......@@ -695,7 +695,7 @@ class TestAccountsAPI(CacheIsolationTestCase, UserAPITestCase):
),
(
[{u"code": u"kw"}, {u"code": u"el"}, {u"code": u"kw"}],
[u'The language_proficiencies field must consist of unique languages']
[u'The language_proficiencies field must consist of unique languages.']
),
)
@ddt.unpack
......@@ -769,7 +769,7 @@ class TestAccountsAPI(CacheIsolationTestCase, UserAPITestCase):
response = self.send_get(client)
if has_full_access:
data = response.data
self.assertEqual(17, len(data))
self.assertEqual(18, 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"])
......
"""
Utility methods for the account settings.
"""
import re
from urlparse import urlparse
from django.conf import settings
from django.utils.translation import ugettext as _
def validate_social_link(platform_name, new_social_link):
"""
Given a new social link for a user, ensure that the link takes one of the
following forms:
1) A valid url that comes from the correct social site.
2) A valid username.
3) A blank value.
"""
formatted_social_link = format_social_link(platform_name, new_social_link)
# Ensure that the new link is valid.
if formatted_social_link is None:
required_url_stub = settings.SOCIAL_PLATFORMS[platform_name]['url_stub']
raise ValueError(_(
' Make sure that you are providing a valid username or a URL that contains "' +
required_url_stub + '". To remove the link from your edX profile, leave this field blank.'
))
def format_social_link(platform_name, new_social_link):
"""
Given a user's social link, returns a safe absolute url for the social link.
Returns the following based on the provided new_social_link:
1) Given an empty string, returns ''
1) Given a valid username, return 'https://www.[platform_name_base][username]'
2) Given a valid URL, return 'https://www.[platform_name_base][username]'
3) Given anything unparseable, returns None
"""
# Blank social links should return '' or None as was passed in.
if not new_social_link:
return new_social_link
url_stub = settings.SOCIAL_PLATFORMS[platform_name]['url_stub']
username = _get_username_from_social_link(platform_name, new_social_link)
if not username:
return None
# For security purposes, always build up the url rather than using input from user.
return 'https://www.{}{}'.format(url_stub, username)
def _get_username_from_social_link(platform_name, new_social_link):
"""
Returns the username given a social link.
Uses the following logic to parse new_social_link into a username:
1) If an empty string, returns it as the username.
2) Given a URL, attempts to parse the username from the url and return it.
3) Given a non-URL, returns the entire string as username if valid.
4) If no valid username is found, returns None.
"""
# Blank social links should return '' or None as was passed in.
if not new_social_link:
return new_social_link
# Parse the social link as if it were a URL.
parse_result = urlparse(new_social_link)
url_domain_and_path = parse_result[1] + parse_result[2]
url_stub = re.escape(settings.SOCIAL_PLATFORMS[platform_name]['url_stub'])
username_match = re.search('(www\.)?' + url_stub + '(?P<username>.*?)[/]?$', url_domain_and_path, re.IGNORECASE)
if username_match:
username = username_match.group('username')
else:
username = new_social_link
# Ensure the username is a valid username.
if not _is_valid_social_username(username):
return None
return username
def _is_valid_social_username(value):
"""
Given a particular string, returns whether the string can be considered a safe username.
A safe username contains only hyphens, underscores or other alphanumerical characters.
"""
return bool(re.match('^[a-zA-Z0-9_-]*$', value))
......@@ -109,6 +109,12 @@ class AccountViewSet(ViewSet):
* requires_parental_consent: True if the user is a minor
requiring parental consent.
* social_links: Array of social links. Each
preference is a JSON object with the following keys:
* "platform": A particular social platform, ex: 'facebook'
* "social_link": The link to the user's profile on the particular platform
* username: The username associated with the account.
* year_of_birth: The year the user was born, as an integer, or null.
* account_privacy: The user's setting for sharing her personal
......
......@@ -103,6 +103,12 @@
});
sectionOneFieldViews = [
new LearnerProfileFieldsView.SocialLinkIconsView({
model: accountSettingsModel,
socialPlatforms: options.social_platforms,
ownProfile: options.own_profile
}),
new FieldsView.DateFieldView({
title: gettext('Joined'),
titleVisible: true,
......
......@@ -53,6 +53,17 @@ define(
});
};
var createSocialLinksView = function(ownProfile, socialPlatformLinks) {
var accountSettingsModel = new UserAccountModel();
accountSettingsModel.set({social_platforms: socialPlatformLinks});
return new LearnerProfileFields.SocialLinkIconsView({
model: accountSettingsModel,
socialPlatforms: ['twitter', 'facebook', 'linkedin'],
ownProfile: ownProfile
});
};
var createFakeImageFile = function(size) {
var fileFakeData = 'i63ljc6giwoskyb9x5sw0169bdcmcxr3cdz8boqv0lik971972cmd6yknvcxr5sw0nvc169bdcmcxsdf';
return new Blob(
......@@ -75,6 +86,7 @@ define(
loadFixtures('learner_profile/fixtures/learner_profile.html');
TemplateHelpers.installTemplate('templates/fields/field_image');
TemplateHelpers.installTemplate('templates/fields/message_banner');
TemplateHelpers.installTemplate('learner_profile/templates/social_icons');
});
afterEach(function() {
......@@ -291,5 +303,76 @@ define(
expect($('.message-banner').text().trim()).toBe(imageView.errorMessage);
});
});
describe('SocialLinkIconsView', function() {
var socialPlatformLinks,
socialLinkData,
socialLinksView,
socialPlatform,
$icon;
it('icons are visible and links to social profile if added in account settings', function() {
socialPlatformLinks = {
twitter: {
platform: 'twitter',
social_link: 'https://www.twitter.com/edX'
},
facebook: {
platform: 'facebook',
social_link: 'https://www.facebook.com/edX'
},
linkedin: {
platform: 'linkedin',
social_link: ''
}
};
socialLinksView = createSocialLinksView(true, socialPlatformLinks);
// Icons should be present and contain links if defined
for (var i = 0; i < Object.keys(socialPlatformLinks); i++) { // eslint-disable-line vars-on-top
socialPlatform = Object.keys(socialPlatformLinks)[i];
socialLinkData = socialPlatformLinks[socialPlatform];
if (socialLinkData.social_link) {
// Icons with a social_link value should be displayed with a surrounding link
$icon = socialLinksView.$('span.fa-' + socialPlatform + '-square');
expect($icon).toExist();
expect($icon.parent().is('a'));
} else {
// Icons without a social_link value should be displayed without a surrounding link
$icon = socialLinksView.$('span.fa-' + socialPlatform + '-square');
expect($icon).toExist();
expect(!$icon.parent().is('a'));
}
}
});
it('icons are not visible on a profile with no links', function() {
socialPlatformLinks = {
twitter: {
platform: 'twitter',
social_link: ''
},
facebook: {
platform: 'facebook',
social_link: ''
},
linkedin: {
platform: 'linkedin',
social_link: ''
}
};
socialLinksView = createSocialLinksView(false, socialPlatformLinks);
// Icons should not be present if not defined on another user's profile
for (var i = 0; i < Object.keys(socialPlatformLinks); i++) { // eslint-disable-line vars-on-top
socialPlatform = Object.keys(socialPlatformLinks)[i];
socialLinkData = socialPlatformLinks[socialPlatform];
$icon = socialLinksView.$('span.fa-' + socialPlatform + '-square');
expect($icon).toBe(null);
}
});
});
});
});
......@@ -81,6 +81,12 @@ define(
});
var sectionOneFieldViews = [
new LearnerProfileFields.SocialLinkIconsView({
model: accountSettingsModel,
socialPlatforms: Helpers.SOCIAL_PLATFORMS,
ownProfile: true
}),
new FieldViews.DropdownFieldView({
title: gettext('Location'),
model: accountSettingsModel,
......
......@@ -2,10 +2,17 @@ define(['underscore', 'URI', 'edx-ui-toolkit/js/utils/spec-helpers/ajax-helpers'
'use strict';
var expectProfileElementContainsField = function(element, view) {
var titleElement, fieldTitle;
var $element = $(element);
var fieldTitle = $element.find('.u-field-title').text().trim();
if (!_.isUndefined(view.options.title)) {
// Avoid testing for elements without titles
titleElement = $element.find('.u-field-title');
if (titleElement.length === 0) {
return;
}
fieldTitle = titleElement.text().trim();
if (!_.isUndefined(view.options.title) && !_.isUndefined(fieldTitle)) {
expect(fieldTitle).toBe(view.options.title);
}
......@@ -41,9 +48,10 @@ define(['underscore', 'URI', 'edx-ui-toolkit/js/utils/spec-helpers/ajax-helpers'
};
var expectSectionOneTobeRendered = function(learnerProfileView) {
var sectionOneFieldElements = $(learnerProfileView.$('.wrapper-profile-section-one')).find('.u-field');
var sectionOneFieldElements = $(learnerProfileView.$('.wrapper-profile-section-one'))
.find('.u-field, .social-links');
expect(sectionOneFieldElements.length).toBe(6);
expect(sectionOneFieldElements.length).toBe(7);
expectProfileElementContainsField(sectionOneFieldElements[0], learnerProfileView.options.profileImageFieldView);
expectProfileElementContainsField(sectionOneFieldElements[1], learnerProfileView.options.usernameFieldView);
expectProfileElementContainsField(sectionOneFieldElements[2], learnerProfileView.options.nameFieldView);
......
......@@ -3,9 +3,17 @@
'use strict';
define([
'gettext', 'jquery', 'underscore', 'backbone', 'edx-ui-toolkit/js/utils/string-utils',
'edx-ui-toolkit/js/utils/html-utils', 'js/views/fields', 'js/views/image_field', 'backbone-super'
], function(gettext, $, _, Backbone, StringUtils, HtmlUtils, FieldViews, ImageFieldView) {
'gettext',
'jquery',
'underscore',
'backbone',
'edx-ui-toolkit/js/utils/string-utils',
'edx-ui-toolkit/js/utils/html-utils',
'js/views/fields',
'js/views/image_field',
'text!learner_profile/templates/social_icons.underscore',
'backbone-super'
], function(gettext, $, _, Backbone, StringUtils, HtmlUtils, FieldViews, ImageFieldView, socialIconsTemplate) {
var LearnerProfileFieldViews = {};
LearnerProfileFieldViews.AccountPrivacyFieldView = FieldViews.DropdownFieldView.extend({
......@@ -122,6 +130,31 @@
}
});
LearnerProfileFieldViews.SocialLinkIconsView = Backbone.View.extend({
initialize: function(options) {
this.options = _.extend({}, options);
},
render: function() {
var socialLinks = {};
for (var platformName in this.options.socialPlatforms) { // eslint-disable-line no-restricted-syntax, guard-for-in, vars-on-top, max-len
socialLinks[platformName] = null;
for (var link in this.model.get('social_links')) { // eslint-disable-line no-restricted-syntax, vars-on-top, max-len
if (platformName === this.model.get('social_links')[link].platform) {
socialLinks[platformName] = this.model.get('social_links')[link].social_link;
}
}
}
HtmlUtils.setHtml(this.$el, HtmlUtils.template(socialIconsTemplate)({
socialLinks: socialLinks,
ownProfile: this.options.ownProfile
}));
return this;
}
});
return LearnerProfileFieldViews;
});
}).call(this, define || RequireJS.define);
<div class="social-links">
<% for (var platform in socialLinks) { %>
<% if (socialLinks[platform]) { %>
<a target="_blank" href= <%-socialLinks[platform]%>>
<span class="icon fa fa-<%-platform%>-square" data-platform=<%-platform%> aria-hidden="true"></span>
</a>
<% } %>
<% } %>
</div>
......@@ -93,6 +93,7 @@ def learner_profile_context(request, profile_username, user_is_staff):
'badges_icon': staticfiles_storage.url('certificates/images/ico-mozillaopenbadges.png'),
'backpack_ui_img': staticfiles_storage.url('certificates/images/backpack-ui.png'),
'platform_name': configuration_helpers.get_value('platform_name', settings.PLATFORM_NAME),
'social_platforms': settings.SOCIAL_PLATFORMS,
},
'disable_courseware_js': True,
}
......
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