Commit f42e6f1c by Harry Rein Committed by GitHub

Merge pull request #15826 from edx/HarryRein/LEANER-1859-social-profile-links

LEARNER-1859: social profile links
parents 0fe31fa9 9f88427f
# -*- 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
......
......@@ -3,19 +3,21 @@ define(['backbone',
'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, FieldViews, FieldViewsSpecHelpers,
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);
......
......@@ -7,7 +7,7 @@ define(['backbone',
'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