From f8b37bbfce92cb267c5b807473de4ab18f87239d Mon Sep 17 00:00:00 2001 From: Usman Khalid <2200617@gmail.com> Date: Fri, 13 Mar 2015 16:35:01 +0500 Subject: [PATCH 01/25] Added javascript library backbone-super. TNL-1499 --- lms/static/js/spec/main.js | 4 + lms/static/js/vendor/backbone-super.js | 114 +++++++++++++++++++++++++ lms/static/require-config-lms.js | 4 + 3 files changed, 122 insertions(+) create mode 100644 lms/static/js/vendor/backbone-super.js diff --git a/lms/static/js/spec/main.js b/lms/static/js/spec/main.js index d3932fc64b60..39bb5a70b2b2 100644 --- a/lms/static/js/spec/main.js +++ b/lms/static/js/spec/main.js @@ -30,6 +30,7 @@ 'backbone': 'xmodule_js/common_static/js/vendor/backbone-min', 'backbone.associations': 'xmodule_js/common_static/js/vendor/backbone-associations-min', 'backbone.paginator': 'xmodule_js/common_static/js/vendor/backbone.paginator.min', + "backbone-super": "js/vendor/backbone-super", 'tinymce': 'xmodule_js/common_static/js/vendor/tinymce/js/tinymce/tinymce.full.min', 'jquery.tinymce': 'xmodule_js/common_static/js/vendor/tinymce/js/tinymce/jquery.tinymce', 'xmodule': 'xmodule_js/src/xmodule', @@ -197,6 +198,9 @@ deps: ['backbone'], exports: 'Backbone.Paginator' }, + "backbone-super": { + deps: ["backbone"], + }, 'youtube': { exports: 'YT' }, diff --git a/lms/static/js/vendor/backbone-super.js b/lms/static/js/vendor/backbone-super.js new file mode 100644 index 000000000000..49f95842a577 --- /dev/null +++ b/lms/static/js/vendor/backbone-super.js @@ -0,0 +1,114 @@ +// https://github.com/lukasolson/backbone-super +// MIT License + +// This is a plugin, constructed from parts of Backbone.js and John Resig's inheritance script. +// (See http://backbonejs.org, http://ejohn.org/blog/simple-javascript-inheritance/) +// No credit goes to me as I did absolutely nothing except patch these two together. +(function(root, factory) { + + // Set up Backbone appropriately for the environment. Start with AMD. + if (typeof define === 'function' && define.amd) { + define(['underscore', 'backbone'], function(_, Backbone) { + // Export global even in AMD case in case this script is loaded with + // others that may still expect a global Backbone. + factory( _, Backbone); + }); + + // Next for Node.js or CommonJS. + } else if (typeof exports !== 'undefined' && typeof require === 'function') { + var _ = require('underscore'), + Backbone = require('backbone'); + factory(_, Backbone); + + // Finally, as a browser global. + } else { + factory(root._, root.Backbone); + } + +}(this, function factory(_, Backbone) { + Backbone.Model.extend = Backbone.Collection.extend = Backbone.Router.extend = Backbone.View.extend = function(protoProps, classProps) { + var child = inherits(this, protoProps, classProps); + child.extend = this.extend; + return child; + }; + var unImplementedSuper = function(method){throw "Super does not implement this method: " + method;}; + + var fnTest = /\b_super\b/; + + var makeWrapper = function(parentProto, name, fn) { + var wrapper = function() { + var tmp = this._super; + + // Add a new ._super() method that is the same method + // but on the super-class + this._super = parentProto[name] || unImplementedSuper(name); + + // The method only need to be bound temporarily, so we + // remove it when we're done executing + var ret; + try { + ret = fn.apply(this, arguments); + } finally { + this._super = tmp; + } + return ret; + }; + + //we must move properties from old function to new + for (var prop in fn) { + wrapper[prop] = fn[prop]; + delete fn[prop]; + } + + return wrapper; + }; + + var ctor = function(){}, inherits = function(parent, protoProps, staticProps) { + var child, parentProto = parent.prototype; + + // The constructor function for the new subclass is either defined by you + // (the "constructor" property in your `extend` definition), or defaulted + // by us to simply call the parent's constructor. + if (protoProps && protoProps.hasOwnProperty('constructor')) { + child = protoProps.constructor; + } else { + child = function(){ return parent.apply(this, arguments); }; + } + + // Inherit class (static) properties from parent. + _.extend(child, parent, staticProps); + + // Set the prototype chain to inherit from `parent`, without calling + // `parent`'s constructor function. + ctor.prototype = parentProto; + child.prototype = new ctor(); + + // Add prototype properties (instance properties) to the subclass, + // if supplied. + if (protoProps) { + _.extend(child.prototype, protoProps); + + // Copy the properties over onto the new prototype + for (var name in protoProps) { + // Check if we're overwriting an existing function + if (typeof protoProps[name] == "function" && fnTest.test(protoProps[name])) { + child.prototype[name] = makeWrapper(parentProto, name, protoProps[name]); + } + } + } + + // Add static properties to the constructor function, if supplied. + if (staticProps) _.extend(child, staticProps); + + // Correctly set child's `prototype.constructor`. + child.prototype.constructor = child; + + // Set a convenience property in case the parent's prototype is needed later. + child.__super__ = parentProto; + + return child; + }; + + return inherits; +})); + diff --git a/lms/static/require-config-lms.js b/lms/static/require-config-lms.js index 3530432f0943..0e4b9ae82c57 100644 --- a/lms/static/require-config-lms.js +++ b/lms/static/require-config-lms.js @@ -47,6 +47,7 @@ "annotator_1.2.9": "js/vendor/edxnotes/annotator-full.min", "date": "js/vendor/date", "backbone": "js/vendor/backbone-min", + "backbone-super": "js/vendor/backbone-super", "underscore.string": "js/vendor/underscore.string.min", // Files needed by OVA "annotator": "js/vendor/ova/annotator-full", @@ -87,6 +88,9 @@ deps: ["underscore", "jquery"], exports: "Backbone" }, + "backbone-super": { + deps: ["backbone"], + }, "logger": { exports: "Logger" }, From 668f256aa72163b0f0e3247ea9ef795a299c3e1c Mon Sep 17 00:00:00 2001 From: Usman Khalid <2200617@gmail.com> Date: Fri, 13 Mar 2015 16:29:14 +0500 Subject: [PATCH 02/25] Account settings page. TNL-1499 --- .../student_account/test/test_views.py | 49 ++- lms/djangoapps/student_account/urls.py | 5 + lms/djangoapps/student_account/views.py | 76 ++++- .../models/user_account_model.js | 28 ++ .../models/user_preferences_model.js | 16 + .../views/account_settings_factory.js | 175 +++++++++++ .../views/account_settings_fields.js | 106 +++++++ .../views/account_settings_view.js | 41 +++ lms/static/js/views/fields.js | 279 ++++++++++++++++++ lms/static/sass/_developer.scss | 16 +- lms/static/sass/application-extend2.scss.mako | 1 + lms/static/sass/application.scss.mako | 1 + lms/static/sass/shared/_fields.scss | 69 +++++ lms/static/sass/views/_account-settings.scss | 69 +++++ .../fields/field_dropdown.underscore | 16 + lms/templates/fields/field_link.underscore | 11 + .../fields/field_readonly.underscore | 9 + lms/templates/fields/field_text.underscore | 9 + lms/templates/navigation-edx.html | 4 +- lms/templates/navigation.html | 4 +- .../student_account/account_settings.html | 39 +++ .../account_settings.underscore | 24 ++ 22 files changed, 1030 insertions(+), 17 deletions(-) create mode 100644 lms/static/js/student_account/models/user_account_model.js create mode 100644 lms/static/js/student_account/models/user_preferences_model.js create mode 100644 lms/static/js/student_account/views/account_settings_factory.js create mode 100644 lms/static/js/student_account/views/account_settings_fields.js create mode 100644 lms/static/js/student_account/views/account_settings_view.js create mode 100644 lms/static/js/views/fields.js create mode 100644 lms/static/sass/shared/_fields.scss create mode 100644 lms/static/sass/views/_account-settings.scss create mode 100644 lms/templates/fields/field_dropdown.underscore create mode 100644 lms/templates/fields/field_link.underscore create mode 100644 lms/templates/fields/field_readonly.underscore create mode 100644 lms/templates/fields/field_text.underscore create mode 100644 lms/templates/student_account/account_settings.html create mode 100644 lms/templates/student_account/account_settings.underscore diff --git a/lms/djangoapps/student_account/test/test_views.py b/lms/djangoapps/student_account/test/test_views.py index c2c99faf9807..7cbf402651e6 100644 --- a/lms/djangoapps/student_account/test/test_views.py +++ b/lms/djangoapps/student_account/test/test_views.py @@ -15,14 +15,15 @@ from django.core import mail from django.test.utils import override_settings -from util.testing import UrlResetMixin -from third_party_auth.tests.testutil import simulate_running_pipeline from embargo.test_utils import restrict_course from openedx.core.djangoapps.user_api.accounts.api import activate_account, create_account from openedx.core.djangoapps.user_api.accounts import EMAIL_MAX_LENGTH +from student.tests.factories import CourseModeFactory, UserFactory +from student_account.views import account_settings_context +from third_party_auth.tests.testutil import simulate_running_pipeline +from util.testing import UrlResetMixin from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.factories import CourseFactory -from student.tests.factories import CourseModeFactory @ddt.ddt @@ -499,3 +500,45 @@ def _third_party_login_url(self, backend_name, auth_entry, course_id=None, redir url=reverse("social:begin", kwargs={"backend": backend_name}), params=urlencode(params) ) + + +class AccountSettingsViewTest(TestCase): + """ Tests for the account settings view. """ + + USERNAME = 'student' + PASSWORD = 'password' + FIELDS = [ + 'country', + 'gender', + 'language', + 'level_of_education', + 'password', + 'year_of_birth', + 'preferred_language', + ] + + def setUp(self): + super(AccountSettingsViewTest, self).setUp() + self.user = UserFactory.create(username=self.USERNAME, password=self.PASSWORD) + self.client.login(username=self.USERNAME, password=self.PASSWORD) + + def test_context(self): + + context = account_settings_context(self.user) + + user_accounts_api_url = reverse("accounts_api", kwargs={'username': self.user.username}) + self.assertEqual(context['user_accounts_api_url'], user_accounts_api_url) + + user_preferences_api_url = reverse('preferences_api', kwargs={'username': self.user.username}) + self.assertEqual(context['user_preferences_api_url'], user_preferences_api_url) + + for attribute in self.FIELDS: + self.assertIn(attribute, context['fields']) + + def test_view(self): + + view_path = reverse('account_settings') + response = self.client.get(path=view_path) + + for attribute in self.FIELDS: + self.assertIn(attribute, response.content) diff --git a/lms/djangoapps/student_account/urls.py b/lms/djangoapps/student_account/urls.py index 4f5eba7c0448..a172f25ba4ff 100644 --- a/lms/djangoapps/student_account/urls.py +++ b/lms/djangoapps/student_account/urls.py @@ -11,3 +11,8 @@ url(r'^register/$', 'login_and_registration_form', {'initial_mode': 'register'}, name='account_register'), url(r'^password$', 'password_change_request_handler', name='password_change_request'), ) + +urlpatterns += patterns( + 'student_account.views', + url(r'^settings$', 'account_settings', name='account_settings'), +) diff --git a/lms/djangoapps/student_account/views.py b/lms/djangoapps/student_account/views.py index 47f2a56c479a..41ebf00980d1 100644 --- a/lms/djangoapps/student_account/views.py +++ b/lms/djangoapps/student_account/views.py @@ -5,30 +5,37 @@ from ipware.ip import get_ip from django.conf import settings +from django.contrib.auth.decorators import login_required from django.http import ( HttpResponse, HttpResponseBadRequest, HttpResponseForbidden ) from django.shortcuts import redirect from django.http import HttpRequest +from django_countries import countries from django.core.urlresolvers import reverse, resolve from django.utils.translation import ugettext as _ from django_future.csrf import ensure_csrf_cookie from django.views.decorators.http import require_http_methods -from opaque_keys.edx.keys import CourseKey +from lang_pref.api import released_languages from opaque_keys import InvalidKeyError +from opaque_keys.edx.keys import CourseKey from edxmako.shortcuts import render_to_response from microsite_configuration import microsite + from embargo import api as embargo_api -import third_party_auth from external_auth.login_and_register import ( login as external_auth_login, register as external_auth_register ) +from student.models import UserProfile from student.views import ( signin_user as old_login_view, register_user as old_register_view ) +from student_account.helpers import auth_pipeline_urls +import third_party_auth +from util.bad_request_rate_limiter import BadRequestRateLimiter from openedx.core.djangoapps.user_api.accounts.api import request_password_change from openedx.core.djangoapps.user_api.errors import UserNotFound @@ -294,3 +301,68 @@ def _external_auth_intercept(request, mode): return external_auth_login(request) elif mode == "register": return external_auth_register(request) + + +@login_required +@require_http_methods(['GET']) +def account_settings(request): + """Render the current user's account settings page. + + Args: + request (HttpRequest) + + Returns: + HttpResponse: 200 if the page was sent successfully + HttpResponse: 302 if not logged in (redirect to login page) + HttpResponse: 405 if using an unsupported HTTP method + + Example usage: + + GET /account/settings + + """ + return render_to_response('student_account/account_settings.html', account_settings_context(request.user)) + + +def account_settings_context(user): + """ Context for the account settings page. + + Args: + user (User): The user for whom the context is required. + + Returns: + dict + + """ + country_options = [ + (country_code, unicode(country_name)) + for country_code, country_name in sorted( + countries.countries, key=lambda(__, name): unicode(name) + ) + ] + + year_of_birth_options = [(unicode(year), unicode(year)) for year in UserProfile.VALID_YEARS] + + context = { + 'user_accounts_api_url': reverse("accounts_api", kwargs={'username': user.username}), + 'user_preferences_api_url': reverse('preferences_api', kwargs={'username': user.username}), + 'fields': { + 'country': { + 'options': country_options, + }, 'gender': { + 'options': UserProfile.GENDER_CHOICES, + }, 'language': { + 'options': released_languages(), + }, 'level_of_education': { + 'options': UserProfile.LEVEL_OF_EDUCATION_CHOICES, + }, 'password': { + 'url': reverse('password_reset'), + }, 'year_of_birth': { + 'options': year_of_birth_options, + }, 'preferred_language': { + 'options': settings.ALL_LANGUAGES, + } + } + } + + return context diff --git a/lms/static/js/student_account/models/user_account_model.js b/lms/static/js/student_account/models/user_account_model.js new file mode 100644 index 000000000000..1eeb5dd8d75a --- /dev/null +++ b/lms/static/js/student_account/models/user_account_model.js @@ -0,0 +1,28 @@ +;(function (define, undefined) { + 'use strict'; + define([ + 'gettext', 'underscore', 'backbone', + ], function (gettext, _, Backbone) { + + var UserAccountModel = Backbone.Model.extend({ + idAttribute: 'username', + defaults: { + username: '', + name: '', + email: '', + password: '', + language: null, + country: null, + date_joined: "", + gender: null, + goals: "", + level_of_education: null, + mailing_address: "", + year_of_birth: null, + language_proficiencies: [] + } + }); + + return UserAccountModel; + }) +}).call(this, define || RequireJS.define); diff --git a/lms/static/js/student_account/models/user_preferences_model.js b/lms/static/js/student_account/models/user_preferences_model.js new file mode 100644 index 000000000000..273e2ea8526c --- /dev/null +++ b/lms/static/js/student_account/models/user_preferences_model.js @@ -0,0 +1,16 @@ +;(function (define, undefined) { + 'use strict'; + define([ + 'gettext', 'underscore', 'backbone', + ], function (gettext, _, Backbone) { + + var UserPreferencesModel = Backbone.Model.extend({ + idAttribute: 'account_privacy', + defaults: { + account_privacy: 'private' + } + }); + + return UserPreferencesModel; + }) +}).call(this, define || RequireJS.define); \ No newline at end of file diff --git a/lms/static/js/student_account/views/account_settings_factory.js b/lms/static/js/student_account/views/account_settings_factory.js new file mode 100644 index 000000000000..c40ece2626be --- /dev/null +++ b/lms/static/js/student_account/views/account_settings_factory.js @@ -0,0 +1,175 @@ +;(function (define, undefined) { + 'use strict'; + define([ + 'gettext', 'jquery', 'underscore', 'backbone', + 'js/views/fields', + 'js/student_account/models/user_account_model', + 'js/student_account/models/user_preferences_model', + 'js/student_account/views/account_settings_fields', + 'js/student_account/views/account_settings_view', + ], function (gettext, $, _, Backbone, FieldViews, UserAccountModel, UserPreferencesModel, + AccountSettingsFieldViews, AccountSettingsView) { + + return function (fieldsData, userAccountsApiUrl, userPreferencesApiUrl) { + + var accountSettingsElement = $('.wrapper-account-settings'); + + var userAccountModel = new UserAccountModel(); + userAccountModel.url = userAccountsApiUrl; + + var userPreferencesModel = new UserPreferencesModel(); + userPreferencesModel.url = userPreferencesApiUrl; + + var sectionsData = [ + { + title: gettext('Basic Account Information (required)'), + fields: [ + { + view: new FieldViews.ReadonlyFieldView({ + model: userAccountModel, + title: gettext('Username'), + valueAttribute: 'username', + helpMessage: '' + }) + }, + { + view: new FieldViews.TextFieldView({ + model: userAccountModel, + title: gettext('Full Name'), + valueAttribute: 'name', + helpMessage: gettext('The name that appears on your edX certificates.') + }) + }, + { + view: new AccountSettingsFieldViews.EmailFieldView({ + model: userAccountModel, + title: gettext('Email'), + valueAttribute: 'email', + helpMessage: gettext('The email address you use to sign in to edX. Communications from edX and your courses are sent to this address.') + }) + }, + { + view: new AccountSettingsFieldViews.PasswordFieldView({ + model: userAccountModel, + title: gettext('Password'), + valueAttribute: 'password', + emailAttribute: 'email', + linkTitle: gettext('Reset Password'), + linkHref: fieldsData['password']['url'], + helpMessage: gettext('When you click "Reset Password", a message will be sent to your email address. Click the link in the message to reset your password.') + }) + }, + { + view: new AccountSettingsFieldViews.LanguagePreferenceFieldView({ + model: userPreferencesModel, + title: 'Language', + valueAttribute: 'pref-lang', + required: true, + refreshPageOnSave: true, + helpMessage: gettext('The language used for the edX site. The site is currently available in a limited number of languages.'), + options: fieldsData['language']['options'] + }) + } + ] + }, + { + title: gettext('Additional Information (optional)'), + fields: [ + { + view: new FieldViews.DropdownFieldView({ + model: userAccountModel, + title: gettext('Education Completed'), + valueAttribute: 'level_of_education', + options: fieldsData['level_of_education']['options'] + }) + }, + { + view: new FieldViews.DropdownFieldView({ + model: userAccountModel, + title: gettext('Gender'), + valueAttribute: 'gender', + options: fieldsData['gender']['options'] + }) + }, + { + view: new FieldViews.DropdownFieldView({ + model: userAccountModel, + title: gettext('Year of Birth'), + valueAttribute: 'year_of_birth', + options: fieldsData['year_of_birth']['options'] + }) + }, + { + view: new FieldViews.DropdownFieldView({ + model: userAccountModel, + title: gettext('Country or Region'), + valueAttribute: 'country', + options: fieldsData['country']['options'] + }) + }, + { + view: new AccountSettingsFieldViews.LanguageProficienciesFieldView({ + model: userAccountModel, + title: gettext('Preferred Language'), + valueAttribute: 'language_proficiencies', + options: fieldsData['preferred_language']['options'] + }) + } + ] + }, + { + title: gettext('Connected Accounts'), + fields: [ + { + view: new FieldViews.LinkFieldView({ + model: userAccountModel, + title: gettext('Facebook'), + valueAttribute: 'auth-facebook', + linkTitle: gettext('Link'), + helpMessage: gettext('Coming soon') + }) + }, + { + view: new FieldViews.LinkFieldView({ + model: userAccountModel, + title: gettext('Google'), + valueAttribute: 'auth-google', + linkTitle: gettext('Link'), + helpMessage: gettext('Coming soon') + }) + } + ] + } + ]; + + var accountSettingsView = new AccountSettingsView({ + el: accountSettingsElement, + sectionsData: sectionsData + }); + + accountSettingsView.render(); + + var showLoadingError = function (model, response, options) { + accountSettingsView.showLoadingError(); + }; + + userAccountModel.fetch({ + success: function (model, response, options) { + userPreferencesModel.fetch({ + success: function (model, response, options) { + accountSettingsView.renderFields(); + }, + error: showLoadingError + }) + }, + error: showLoadingError + }); + + return { + userAccountModel: userAccountModel, + userPreferencesModel: userPreferencesModel, + accountSettingsView: accountSettingsView + }; + }; + }) +}).call(this, define || RequireJS.define); diff --git a/lms/static/js/student_account/views/account_settings_fields.js b/lms/static/js/student_account/views/account_settings_fields.js new file mode 100644 index 000000000000..c6bb3f5c613a --- /dev/null +++ b/lms/static/js/student_account/views/account_settings_fields.js @@ -0,0 +1,106 @@ +;(function (define, undefined) { + 'use strict'; + define([ + 'gettext', 'jquery', 'underscore', 'backbone', 'js/mustache', 'js/views/fields' + ], function (gettext, $, _, Backbone, RequireMustache, FieldViews) { + + var Mustache = window.Mustache || RequireMustache; + + var AccountSettingsFieldViews = {}; + + AccountSettingsFieldViews.EmailFieldView = FieldViews.TextFieldView.extend({ + + successMessage: function() { + return this.indicators['success'] + interpolate_text( + gettext('We\'ve sent a confirmation message to {new_email_address}. Click the link in the message to update your email address.'), + {'new_email_address': this.fieldValue()} + ); + } + }); + + AccountSettingsFieldViews.LanguagePreferenceFieldView = FieldViews.DropdownFieldView.extend({ + + saveSucceeded: function () { + var data = { + 'language': this.modelValue() + }; + + var view = this; + $.ajax({ + type: 'POST', + url: '/i18n/setlang/', + data: data, + dataType: 'html', + success: function (data, status, xhr) { + view.showSuccessMessage(); + }, + error: function (xhr, status, error) { + view.message( + view.indicators['error'] + gettext('You must sign out of edX and sign back in before your language changes take effect.') + ); + } + }); + } + + }); + + AccountSettingsFieldViews.PasswordFieldView = FieldViews.LinkFieldView.extend({ + + initialize: function (options) { + this._super(options); + _.bindAll(this, 'resetPassword'); + }, + + linkClicked: function (event) { + event.preventDefault(); + this.resetPassword(event) + }, + + resetPassword: function (event) { + var data = {}; + data[this.options.emailAttribute] = this.model.get(this.options.emailAttribute); + + var view = this; + $.ajax({ + type: 'POST', + url: view.options.linkHref, + data: data, + success: function (data, status, xhr) { + view.showSuccessMessage() + }, + error: function (xhr, status, error) { + view.showErrorMessage(xhr); + } + }); + }, + + successMessage: function () { + return this.indicators['success'] + interpolate_text( + gettext('We\'ve sent a message to {email_address}. Click the link in the message to reset your password.'), + {'email_address': this.model.get(this.options.emailAttribute)} + ); + }, + }); + + AccountSettingsFieldViews.LanguageProficienciesFieldView = FieldViews.DropdownFieldView.extend({ + + modelValue: function () { + var modelValue = this.model.get(this.options.valueAttribute); + if (_.isArray(modelValue) && modelValue.length > 0) { + return modelValue[0].code + } else { + return ''; + } + }, + + saveValue: function () { + var attributes = {}; + var value = this.fieldValue() ? [{'code': this.fieldValue()}] : []; + attributes[this.options.valueAttribute] = value; + this.saveAttributes(attributes); + } + }); + + return AccountSettingsFieldViews; + }) +}).call(this, define || RequireJS.define); diff --git a/lms/static/js/student_account/views/account_settings_view.js b/lms/static/js/student_account/views/account_settings_view.js new file mode 100644 index 000000000000..20222f8ea441 --- /dev/null +++ b/lms/static/js/student_account/views/account_settings_view.js @@ -0,0 +1,41 @@ +;(function (define, undefined) { + 'use strict'; + define([ + 'gettext', 'jquery', 'underscore', 'backbone', + ], function (gettext, $, _, Backbone) { + + var AccountSettingsView = Backbone.View.extend({ + + initialize: function (options) { + this.template = _.template($('#account_settings-tpl').text()); + _.bindAll(this, 'render', 'renderFields', 'showLoadingError'); + }, + + render: function () { + this.$el.html(this.template({ + sections: this.options.sectionsData + })); + return this; + }, + + renderFields: function () { + this.$('.ui-loading-indicator').addClass('is-hidden'); + + var view = this; + _.each(this.$('.account-settings-section-body'), function (sectionEl, index) { + _.each(view.options.sectionsData[index].fields, function (field, index) { + $(sectionEl).append(field.view.render().el); + }); + }); + return this; + }, + + showLoadingError: function () { + this.$('.ui-loading-indicator').addClass('is-hidden'); + this.$('.ui-loading-error').removeClass('is-hidden'); + } + }); + + return AccountSettingsView; + }) +}).call(this, define || RequireJS.define); diff --git a/lms/static/js/views/fields.js b/lms/static/js/views/fields.js new file mode 100644 index 000000000000..6ad13195922d --- /dev/null +++ b/lms/static/js/views/fields.js @@ -0,0 +1,279 @@ +;(function (define, undefined) { + 'use strict'; + define([ + 'gettext', 'jquery', 'underscore', 'backbone', 'js/mustache', 'backbone-super' + ], function (gettext, $, _, Backbone, RequireMustache) { + + var Mustache = window.Mustache || RequireMustache; + + var messageRevertDelay = 4000; + var FieldViews = {}; + + FieldViews.FieldView = Backbone.View.extend({ + + fieldType: 'generic', + + className: function () { + return 'u-field' + ' u-field-' + this.fieldType + ' u-field-' + this.options.valueAttribute; + }, + + tagName: 'div', + + indicators: { + 'error': '', + 'validationError': '', + 'inProgress': '', + 'success': '' + }, + + messages: { + 'error': gettext('An error occurred. Please try again.'), + 'validationError': '', + 'inProgress': gettext('Saving'), + 'success': gettext('Your changes have been saved.') + }, + + initialize: function (options) { + + this.template = _.template($(this.templateSelector).text()), + + this.helpMessage = this.options.helpMessage || ''; + this.showMessages = _.isUndefined(this.options.showMessages) ? true : this.options.showMessages; + + _.bindAll(this, 'modelValue', 'saveAttributes', 'saveSucceeded', 'getMessage', + 'message', 'showHelpMessage', 'showInProgressMessage', 'showSuccessMessage', 'showErrorMessage'); + }, + + modelValue: function () { + return this.model.get(this.options.valueAttribute); + }, + + saveAttributes: function (attributes, options) { + var view = this; + var defaultOptions = { + contentType: 'application/merge-patch+json', + patch: true, + wait: true, + success: function (model, response, options) { + view.saveSucceeded() + }, + error: function (model, xhr, options) { + view.showErrorMessage(xhr) + } + }; + this.showInProgressMessage(); + this.model.save(attributes, _.extend(defaultOptions, options)); + }, + + saveSucceeded: function () { + this.showSuccessMessage(); + }, + + message: function (message) { + return this.$('.u-field-message').html(message); + }, + + getMessage: function(message_status) { + if ((message_status + 'Message') in this) { + return this[message_status + 'Message'].call(this); + } else if (this.showMessages) { + return this.indicators[message_status] + this.messages[message_status]; + } + return this.indicators[message_status]; + }, + + showHelpMessage: function () { + this.message(this.helpMessage); + }, + + showInProgressMessage: function () { + this.message(this.getMessage('inProgress')); + }, + + showSuccessMessage: function () { + var successMessage = this.getMessage('success'); + this.message(successMessage); + + if (this.options.refreshPageOnSave) { + document.location.reload(); + } + + var view = this; + + var context = Date.now(); + this.lastSuccessMessageContext = context; + + setTimeout(function () { + if ((context === view.lastSuccessMessageContext) && (view.message().html() == successMessage)) { + view.showHelpMessage(); + } + }, messageRevertDelay); + }, + + showErrorMessage: function (xhr) { + if (xhr.status === 400) { + try { + var errors = JSON.parse(xhr.responseText); + var validationErrorMessage = Mustache.escapeHtml(errors['field_errors'][this.options.valueAttribute]['user_message']); + var message = this.indicators['validationError'] + validationErrorMessage; + this.message(message); + } catch (error) { + this.message(this.getMessage('error')); + } + } else { + this.message(this.getMessage('error')); + } + } + }); + + FieldViews.ReadonlyFieldView = FieldViews.FieldView.extend({ + + fieldType: 'readonly', + + templateSelector: '#field_readonly-tpl', + + initialize: function (options) { + this._super(options); + _.bindAll(this, 'render', 'fieldValue', 'updateValueInField'); + this.listenTo(this.model, "change:" + this.options.valueAttribute, this.updateValueInField); + }, + + render: function () { + this.$el.html(this.template({ + id: this.options.valueAttribute, + title: this.options.title, + value: this.modelValue(), + message: this.helpMessage + })); + return this; + }, + + fieldValue: function () { + return this.$('.u-field-value input').val(); + }, + + updateValueInField: function () { + this.$('.u-field-value input').val(Mustache.escapeHtml(this.modelValue())); + } + }); + + FieldViews.TextFieldView = FieldViews.FieldView.extend({ + + fieldType: 'text', + + templateSelector: '#field_text-tpl', + + events: { + 'change input': 'saveValue' + }, + + initialize: function (options) { + this._super(options); + _.bindAll(this, 'render', 'fieldValue', 'updateValueInField', 'saveValue'); + this.listenTo(this.model, "change:" + this.options.valueAttribute, this.updateValueInField); + }, + + render: function () { + this.$el.html(this.template({ + id: this.options.valueAttribute, + title: this.options.title, + value: this.modelValue(), + message: this.helpMessage + })); + return this; + }, + + fieldValue: function () { + return this.$('.u-field-value input').val(); + }, + + updateValueInField: function () { + var value = (_.isUndefined(this.modelValue()) || _.isNull(this.modelValue())) ? '' : this.modelValue(); + this.$('.u-field-value input').val(Mustache.escapeHtml(value)); + }, + + saveValue: function (event) { + var attributes = {}; + attributes[this.options.valueAttribute] = this.fieldValue(); + this.saveAttributes(attributes); + } + }); + + FieldViews.DropdownFieldView = FieldViews.FieldView.extend({ + + fieldType: 'dropdown', + + templateSelector: '#field_dropdown-tpl', + + events: { + 'change select': 'saveValue' + }, + + initialize: function (options) { + this._super(options); + _.bindAll(this, 'render', 'fieldValue', 'updateValueInField', 'saveValue'); + this.listenTo(this.model, "change:" + this.options.valueAttribute, this.updateValueInField); + }, + + render: function () { + this.$el.html(this.template({ + id: this.options.valueAttribute, + title: this.options.title, + required: this.options.required, + selectOptions: this.options.options, + message: this.helpMessage, + })); + this.updateValueInField() + return this; + }, + + fieldValue: function () { + return this.$('.u-field-value select').val(); + }, + + updateValueInField: function () { + var value = (_.isUndefined(this.modelValue()) || _.isNull(this.modelValue())) ? '' : this.modelValue(); + this.$('.u-field-value select').val(Mustache.escapeHtml(value)); + }, + + saveValue: function () { + var attributes = {}; + attributes[this.options.valueAttribute] = this.fieldValue(); + this.saveAttributes(attributes); + } + }); + + FieldViews.LinkFieldView = FieldViews.FieldView.extend({ + + fieldType: 'link', + + templateSelector: '#field_link-tpl', + + events: { + 'click a': 'linkClicked' + }, + + initialize: function (options) { + this._super(options); + _.bindAll(this, 'render', 'linkClicked'); + }, + + render: function () { + this.$el.html(this.template({ + id: this.options.valueAttribute, + title: this.options.title, + linkTitle: this.options.linkTitle, + linkHref: this.options.linkHref, + message: this.helpMessage, + })); + return this; + }, + + linkClicked: function () { + event.preventDefault(); + } + }); + + return FieldViews; + }) +}).call(this, define || RequireJS.define); diff --git a/lms/static/sass/_developer.scss b/lms/static/sass/_developer.scss index b6c643b8d5f9..434add48ae5e 100644 --- a/lms/static/sass/_developer.scss +++ b/lms/static/sass/_developer.scss @@ -28,14 +28,9 @@ @include animation(rotateCW $tmg-s1 linear infinite); } -.ui-loading { +.ui-loading-base { @include animation(fadeIn $tmg-f2 linear 1); - @extend %ui-well; @extend %t-copy-base; - opacity: .6; - background-color: $white; - padding: ($baseline*1.5) $baseline; - text-align: center; .spin { @extend %anim-rotateCW; @@ -46,3 +41,12 @@ padding-left: ($baseline/4); } } + +.ui-loading { + @extend .ui-loading-base; + @extend %ui-well; + opacity: 0.6; + background-color: $white; + padding: ($baseline*1.5) $baseline; + text-align: center; +} diff --git a/lms/static/sass/application-extend2.scss.mako b/lms/static/sass/application-extend2.scss.mako index e290603a4ae4..d59ff93f4491 100644 --- a/lms/static/sass/application-extend2.scss.mako +++ b/lms/static/sass/application-extend2.scss.mako @@ -45,6 +45,7 @@ @import 'elements/system-feedback'; // base - specific views +@import "views/account-settings"; @import 'views/login-register'; @import 'views/verification'; @import 'views/decoupled-verification'; diff --git a/lms/static/sass/application.scss.mako b/lms/static/sass/application.scss.mako index 80a8e95a8337..43113471c896 100644 --- a/lms/static/sass/application.scss.mako +++ b/lms/static/sass/application.scss.mako @@ -43,6 +43,7 @@ @import 'elements/controls'; // shared - course +@import 'shared/fields'; @import 'shared/forms'; @import 'shared/footer'; @import 'shared/header'; diff --git a/lms/static/sass/shared/_fields.scss b/lms/static/sass/shared/_fields.scss new file mode 100644 index 000000000000..0f14daf16d73 --- /dev/null +++ b/lms/static/sass/shared/_fields.scss @@ -0,0 +1,69 @@ +// lms - shared - fields +// ==================== + + +.u-field { + padding: $baseline 0; + border-bottom: 1px solid $gray-l5; + + .message-error { + color: $alert-color; + } + + .message-validation-error { + color: $warning-color; + } + + .message-in-progress { + color: $gray-d2; + } + + .message-success { + color: $success-color; + } +} + +.u-field-readonly { + input[type="text"], + input[type="text"]:focus { + background-color: transparent; + padding: 0px; + border: none; + box-shadow: none; + } +} + +.u-field-title { + width: flex-grid(3, 12); + display: inline-block; + color: $dark-gray1; + vertical-align: top; + margin-bottom: 0; + + label { + @include margin-left($baseline/2); + } +} + +.u-field-value { + width: flex-grid(3, 12); + display: inline-block; + vertical-align: top; + + select, input { + width: 100%; + } +} + +.u-field-message { + @extend small; + @include padding-left($baseline/2); + width: flex-grid(6, 12); + display: inline-block; + vertical-align: top; + color: $dark-gray1; + + i { + @include margin-right($baseline/4); + } +} diff --git a/lms/static/sass/views/_account-settings.scss b/lms/static/sass/views/_account-settings.scss new file mode 100644 index 000000000000..7b204d4cfc23 --- /dev/null +++ b/lms/static/sass/views/_account-settings.scss @@ -0,0 +1,69 @@ +// lms - application - account settings +// ==================== + +// Table of Contents +// * +Container - Account Settings +// * +Main - Header +// * +Settings Section + + +// +Container - Account Settings +.wrapper-account-settings { + @extend .container; + padding-top: ($baseline*2); + + .account-settings-container { + padding: 0; + } + + .ui-loading-indicator, + .ui-loading-error { + @extend .ui-loading-base; + // center horizontally + @include margin-left(auto); + @include margin-right(auto); + padding: ($baseline*3); + + text-align: center; + + .message-error { + color: $alert-color; + } + } +} + +// +Main - Header +.wrapper-account-settings { + + .wrapper-header { + + .header-title { + @extend %t-title4; + margin-bottom: ($baseline/2); + } + + .header-subtitle { + color: $gray-l2; + } + } +} + +// +Settings Section +.account-settings-sections { + + .section-header { + @extend %t-title6; + @extend %t-strong; + padding-bottom: ($baseline/2); + border-bottom: 1px solid $gray-l4; + } + + .section { + background-color: $white; + padding: $baseline; + margin-top: $baseline; + border: 1px solid $gray-l4; + box-shadow: 0 0 1px 1px $shadow-l2; + border-radius: 5px; + } +} diff --git a/lms/templates/fields/field_dropdown.underscore b/lms/templates/fields/field_dropdown.underscore new file mode 100644 index 000000000000..365e76d52c5c --- /dev/null +++ b/lms/templates/fields/field_dropdown.underscore @@ -0,0 +1,16 @@ + + + + + diff --git a/lms/templates/fields/field_link.underscore b/lms/templates/fields/field_link.underscore new file mode 100644 index 000000000000..fbdfad2beab1 --- /dev/null +++ b/lms/templates/fields/field_link.underscore @@ -0,0 +1,11 @@ + + + + <%- gettext(linkTitle) %> + + + diff --git a/lms/templates/fields/field_readonly.underscore b/lms/templates/fields/field_readonly.underscore new file mode 100644 index 000000000000..c8aa65177802 --- /dev/null +++ b/lms/templates/fields/field_readonly.underscore @@ -0,0 +1,9 @@ + + + + + diff --git a/lms/templates/fields/field_text.underscore b/lms/templates/fields/field_text.underscore new file mode 100644 index 000000000000..dd876f31cb9f --- /dev/null +++ b/lms/templates/fields/field_text.underscore @@ -0,0 +1,9 @@ + + + + + diff --git a/lms/templates/navigation-edx.html b/lms/templates/navigation-edx.html index 5658e242ca72..837effb1670f 100644 --- a/lms/templates/navigation-edx.html +++ b/lms/templates/navigation-edx.html @@ -82,9 +82,7 @@
c^Ubxo|o!jdGM&U-Sdr( zMY~#;T=1^gD8^`W;z_^*xh2B)@9mK1jdc2_mGSSYiZ)|epKAeM$j6fpp6%SFy=k3k z=oFR5AAZiO<=J|%*W*~d__X`as;A!qrkCe?;=g~W?-hNxB|QGy;eYe4=HH$h`j7Xu9czXBoJd-_{o$Q&lW@fG5`px2W hnOzJf$}h<)Ajp6A5Q2*rFOps+r6D7u;lIgnlmEZ{I&A %KgXJ+OWmi7*gPR=f_ZvL+V0)t+^366}4 zj){$XAD@6u&&bTm&dJUD^trUGyrQzIy0Ph7b4zPmdk3a}U~p)7WOQtLW_Iq^{KDeW z^49jw?%qE3;PB{7E&_n?53#`aKLq<1xhO%o&Jht25)q%tMR3j;EQFLq=Wp^|xGIAp zHnP2Ti{F<7axdaT$=8c40&1I7#&&&|uCoeGvu&M;_FJ<5JHh<^N0R+ruz$!k29OdG zfRjf^3BZ9vd@wZa+<(e{bnyS)8Xyz|P+Hst) pLfspx8ogL@u`Q$V^3&W9KDZoD>g=jeR KTf{Q?9AtJ#}{qVbRC5$B&%(7YkJmkY(`0J)7@w zf8I &7hUk zou5<5%zswkM(Q8Glqep?O+M-SFK#n6W W{GTZe%X(z3>_ %KKxJuXrVit%(7xA-`>W&=km!=k4`H7%85PR zgMOz_lP=VtMY&FJbuNUC%l*&h^uJd|23GI<*)bM(^JoEgAhLk`d*x&9h}6Hy4f%^e z5Y%J(^AMEuf4hKojWB&vjHQLn)_{B#7wRtmY;we@Ma6B)kci=+N#zu9IR#h@2Bti} zWF-LOQ ^eNy}jy}_V4mx;bhO`d> z0iK2QAplU#YQECvPWFz1x9?D$0&U%%_!GCq XLZZ{S?I@oOq;?^H^ nb3Z199w{{P|Z0_Zy GXUzJ$fc1I@&s{&GXmMaD+=S zUe;;@yOPgL$s=NREUxaKTBoN~=9LheZ|m|JW%@h-mfzjQr<#ABMqsq#uI9%*HqPK- z1aQGMV mdQ>xzOxj=sQ=>t|6VSg`4Dcwa1d7Om@@NgQvj3 zqgd} svqYcYP#1z*VhvGeMB}RR a`+Se?9`71o? z#!cUq72T~ pSzjm%|nlcC7q3-(@|n+-!ORyBGjRm7YsM_-o=Ou21H1H~Zu9AAkXgw|p1!(_9D z6g`3)`MPAw(pOvZv9Q)cm#Q!_-7y)ffg`;{KJSK{6L{OtZMR-mDId%`nK{QSY%%1> zDoAAu9n1j`DDGyYOZJ{i0p`Ojhz%_9QJLeGM}9iGzI9csDgIS?!7oNHsi`U}En2vk zG(}q3AAVwtwN0HA$;Gd^C8@R&Q8v?XK&;9L#2R+<+_YkB(jQ;eed0Qg#2@bE5AKd| zbAGs7H@YR30-LdX40n7^3|mA8uOVZ>$>jkyGxgqaj=P!8MRLBi;BO`5!qjxWpjRmi zV7b1+!ts5dVJL8Z?}TesgZ3CEnV@9weEJlarX1L|yVbT)U7Eh>SQ$!Mq0A7TM9@2| z=!*$1x?fGJ#U!z8pZ#?xc|C)8NghN3j@h`A_CswXLh6ClNpc`wJxPkZv=!$mpPH#z zxA`hqeO2u+Ej+27Mrv@V4|(ght2N_l!AlVXx&fXWJNi@uNk-&K&$?eoa+3B|lly#Z zcJrCdhO|!y|2n5a*XA>Kfss>>Ts&UTuwm6zpK5C2z)HC$slAKAv#ph?d+NY?yT^+2 zqUM_hWjUrjULGb>k1u76gMDuh64o?+ Ya~>IC5q^iF}P&v>f!SRK>AL@q@WQ>xMs;aHh= zrWpG!ubO%pX(kGz=?QJs+OQ#dG1Q#s1q5gMvaPPl=IpmiVZUzNghUc0Z7ms8>%aaA z&lDjPOGAQ2_~|lyXWG8?L;yam TMph^3T?;WtWWGH zKjnM=bdS=}OWHad9#GGv3*M5VE91Z iSTl!Kw)abH84dEiRBel z;Y3XIpD9p=LSc>Wv^zZU`&9-{N(2v|p3JGDgSmQOs%T&3%PGKI@40V*@>Cc2zY(PB z?5U6)Jma}ez})~_2`OP@Ve%`%u}|Od6=GDlYu}|R;_Xy4MI*{sbEI7r_w!J@G+|fD zOtkCHae~SK=%e>l5-vf}zGrG&f8rg5fM~a+_Wd?g=wK-0aRAuv2L@7v#c5c 2AU$2*Vs&1w^FcP#lSQjOH$U-y(+9EFLmOO1 zo_ttSo%IZtppH{7EO0Q3p+q?93 -DQ7EEc!zUxgxBhZce)Z+gzKMam^VsZfrR6XjVl$WLF?5*G@&tJ@GTdzhd_ z>p9UT6}|ez&JEcl*KfbPv(e#C*Wku9onKW}R`E$oSHk=!DPTm?vg$T4pboUs%-XCV zMfU{%5b#gN09g1ScBJYm>0^%exmjISQ72vDv^5?;1hq}B+AX5jKCqx)^JJQ}xTILn zn!9?v=IE}(Zsrpy_sa4V%#5P@>oKu(Q3va^0a&wpV8!$c0Yf7p4FC5W6zlzvRIR<@ zTSKLabrdKo-LeHSBqOUKO3n<869D0u ru;4or3m9i&Y@AWt>xaTAvFRjD4dK+d=qN_|lOBVFs zIqDMouyyC#p5ENfa2*eOw&WG^jY6v3HxqgX Na*Il8`Ye=`}R%jeu}Os9IENLO&rT)Nd1ks zIPO) P=FIxGA7jiSURCYm^{cxzrlwgiS) zM!lm;oqeTN$ PjXc!=x2a&kHjL2b*!juVy}j66#ZGfG82>$x@c z|MJJio1K>i7JxgjmmGE!jKn(`8_O{B;tT_1bNP<}H9HF^Iv@wsbb6uRR|E|N)z#1d zZnrs=#h2({fp>gEKA+bisHr2Av>r5Fvai?8*!S=IV(aS~@_tHSSgGnRT_y89ffWt2 zUWYX;{DigXBbvJtr6yg5>&J8@w&D%RUX~rsW}b7&6ZxD`TB3Xrp>m>*FwV;^FK*~s zxx7+ssYEcP;Ik()u()RX6sDoBWggz}4WN;4V@;p7ZP{f*ULuuCD=M2;3-qtTrta4N zhM~?dmHR%3!C^gO8yZ5%bp84zRuUh9Qy|vPIke7xX-#`EjokTbHEifjrv8{dGU!;= zzo4bCa@>YHuF|f>8F5Bz0M}175={S)ZRpW zd y^RjnsFXQqnuwUgI7ZqQ zia1K21=%hO%c+?g&}zJ&aQfumPBpKCeYGxld@wZN`ce7VcppAd3 $YcN>|h*~|Om7?F=b))(+Z;Z7gRgn4V{aM$;IC@+DyZr!0 zYalo8CCazmpf-QNk-dgGZRJ1>Bn (a|Q(y*Y^PoWfD~j5jA&mkrJ;PsNorHtHndoR?g~_w!WqwU`8_lJ(XbIT&lX zl^jBL!l*UPjpD-HMah*c&J0|hcxt9yLU?e|?Fp*1?evM5cb==-uYQ^Abt#L-oPV|_ z{ )mn7U%q6 z=H_Bph6~0z)g+u*2R<#Bi1(Pqt;C>XMte>^yuq8~8`5^jky+3XZOg3bw9mZYnYdT# zlSCRZS$h=6J{0!MLPg-4%<{S$S`iXjE*2@fQbum8Qp{Ukoie1te^E!Ig`dn}%=zuh z>s)h(RJL_eTT*)po;@lQiH+8S4&v3EqGrMKL<#Jq;k{vl>|Q^I5>sk*B5Uu(bNfND zbl%F!uod2I8-D%t80NwUzn&s=l3oWsCM|k|hjc)TwAOpX$|stU=Hqf1e%UvUhn>-u zCaBWG*E{b#X2&Ywg}VJVsVcSd!|VRX&3llU)$e+mRBB9S=)Q-+nh9(%uG+MaQ$S(r z!?fVgG=k}b_$72B++ddknx~UeXp(VXFs|YveM=%P3AYLhuszED*69#@XZ7tfi;E{4 zW5hK3Y2*i7ciYsEIgruEPeR%SNYWXoy@(eK_;L>Vb?WQfqpRIDpHpyN3!qANELx5$ zZ<$=iTYO+=W324v3c*r(8+TFJti^`|7cVI3Y|k-och_?s4l=YXFDq2P(+o1vnc9rD zdIh2yt+`ZeP9@WvjCvpuvm002ZqI9-mSoFbIhJa+ii_;Og36r5;h_$pde1GqI>lw_ zR79s~u;<>sUR+E&8T5MDbYq`oEDWa(po5`*Ed(G*R%lr0LSA+X+^pF+TA%6~uK&Y- zh!2DQqyEf&$eCW!QQ!c|oRtQO1Z%!km%oU-W4IrbiOMD?ck5S)8CT-q5*XQ4vi_m- z`P#RyrVR2Fj#86@nFh$|3G>B-*Ht5okDhz%U0&NTD7JDuzxGK_SB{CAmXt!pWW!L= zx0$o=SL{N|vnubwWqTR-g4kfx%GkSM*SYhIi)bI}Om?x3l)4 7`N&{^ z!~I!gv>8i13_1cM;?BuT9@HRLWFRoGpQ)#!KN-6Ipb(uTfsH;s)~XNjP$a7>9n-IR zY#MF+j5vVFf3kLNVROykx=~2YzQ$y-N?r&5y5`z)q1)wDRcIC+S8K^G+uOHQ>k9E% zW cymhZ41kQyp7_+Iskku3 z$GW%SH7R?2yRCJuLy1m9;;*zX_cB~@pRc2^J2b`Sgz(O^rZKLVOQi4$$8oQho4nD~ zdiLal?7T@IGPfAM#r#dws}mL*SvVhQA`rD5v!~O&l^XIrLsz+9uP`!oQX%_-`r6u> zo{ShHXOV5>_@tge_xyO*j(muXeN=Fe7$jPaz#akm_f-s`KM3|D#M(DD ~b>MT6nuz6<`%75GOK-4gRVA@u&@@}d1xv(#L z{VpQ
Ul>Isqjg$hEs)%K$)E88)o8B{Dc!e)n} zFHPev7YT-pISw|AU3fw<7@lQSDfyJ#Q^`}AuGXq4p80ZUqvB=t2;BR?VDa$m6@&xB z^E0mmBH}b7#VH6y=j1y#@&Vwf3G0>~_U&Fa#-Wd~G^awq)*l<=UCW8D$qS`cu c{98UT zR;=Lo7Dy8uE{q>d-K5arFi#^agE(dU#)9WF{>Dn`aB!Fdv_(zR>k%g(iW1XrJ^#dH zBD<0*kkf2OSmzer^a858WN(@EJTcOE`xL11AMzPxe3ov)^udhv0YOsC{(dnmR>V&> z7}3P>m1=2x?ivU!i;&G)Qkt0Yw8N*Tz~fa*(`&sATBTL2-<^V?1>uj+A_WKXpGOWy z{k4Wg2E5@WPa |Z$lTI=S|uVoo0)y9w8^H!gzy( zh^1PuMaX2p%=4r?KxO_Dx5|NFqe&x!QR~$7ZeZ3i_h?>rjt0F%xI4vrRHwvQdAw$M z-0vKh6a!*ja*8@ e2tDmU<$E&)y{A*F4D=^jE z+I^-6W(tzq%N~LDVVc>i=u-WA!O-G|o1no-lL2iFU_Q%Ixv_zOJaQJLU>vhFaMN$U z6K@)t8+Oi}SzS-Xa=#9#Trv3E0P{;ev`Rg-da`WpXy}bSdh$Ixk$;Dr_}#tY xE9!FU>ZkAC_=>d&wcs6We{2`%%XYE&y?UDA7Yxmnc!xq-+VL(?d7A5n z=VqHR$C1Flxea<+jwyEEHa8-4a(;q6SL_=AxT)U;ap&XumH49Xx?GHV9NCDbw(XYV zmdKbdTSpizWHao~nL6_fzYFLa($3$`{#0e9*k-MHpGHqqeTq0!JChMP ($;Bec09inQU zaV8{4X8ZvsU;>W~ZuNby%JPh7V14b}itl&3rc;lJ3v1%^RtPQ~e6-y*7GTkrkR*Z( zni%Ugv)fb-V7u>^mG-sSEhuCcIj|bLJVfRG-qp#lwsVoID_%~Ng@}RK^6 i5vyrOvuR$dtns&0LHtwt+gNMr4}_*ahr)=4OURLdrt+Q}-R@~M|YES~d6!!WC) z%EDJmJ>W4-iRjrZ5$)x!$y%r9aCWESJlRAG6|)N>@_V-)CgNcWU~v3GW#g=xbyn3c zwn_JH(zZyYe~4I*-)LJ zLox4xA$Y_?)q3Kdut$k`9{^H7K4&BUKLt;x!0+hkD}+N@FnapPIfIYOM5fo;v9lo} z^keSHTy`2$s!qCziPQ(D*rC3-h#tU+sceOg{0&pklc7%b_uE%65xuFFYhR7@Eml5B zj(0MA9N0ZbX|AEa{6ct;XHAvWQXK1TuT^)9@fPP=nxLE!h%c=V qT|6i zUL{p0C{;TOnfWZlW7jGlNn)*gRc@peON LPtD3efd(A1H< zu +mKTB`55Qsmxe^SeSJrmX!B&;e2{~m9D4uH*&n91@5w;Y5cXx+k zqcJ?jbES5vzt{~K$`*=tAiEaAFLGm;m(%Z?dWCugVTg-Po~L-qb&VsG{@zB@p=nLT zD(`IIM3^S)GPF1;_IjRE$vJH&P{@9-{8tdJ4eKEJn^0Gj0zwWx3UG*@Yn6fj{n; z{N)aM5tbnlhEDtfKU@Lrj_koHuuuS6L28+`o8yj OYNM@9J5Yid3i~D zaK1{FB9SAy@Dwne+^?{Nf7 nA!ihuJK1R*lAVzF)tSH+09S@E0Bnhpi3B- z`9-F966X${ZqLUEb`Cni%_P->E#fYHtRCpYdp7gi9AFdiRb{Yxr+Zb1Ct{={LOT1) zV`Ci_Y{}=SH~}#+p >EtR|5(w+9=iYaiYAB(=FlGUfiX{qz3)#v+F$ zmFi6xQ(`WQ`qv{!H6p6r4x(b~N6jo~va+Fn1D;GXn WAR+TwmBj3S^}JN8z3 zF7F5M*ezT1%qno$`XMvVC9-6oN+abcsT2i7j1-h~)zC=^a``#S!eg3I`ucZCq?WPv zO9rUIm1|Zy{(9d5JOqW~8qu7hvU*dvER&{o^YwEz6^f1Eh1Nr%9T%K#KCk9x`Wk;% zozaJ$J8$sB87}J0iXIUb!v*;-eYjP6@2-{&KbZ@gEp*Z0lO@gYqab3k8mbj5)?Lx! zd@Wh?T7x?xs-3WMuV}TNw)?nhqjyIjq$Ucx8=I_@pvEWbno(@{VFwf ilBMl+Ln_(7Kr`Ed)#B_MYg_ThUJkCsC;Pai(wHr?C2bSj@IRWORjL1PKZl<^ zBD(ba=#7}RUq5z=tvAba#(IKYtm{BRir1aUr`?AGgZC%`6QYN&Q|lPLd7w2_ca^+~ zz3%RdFWHV?SK*v+f *GysvU|hH~^up8od|uGWN`P^GWLip@2DzQ <5a__g3*)328Qu`h^&R2iZW#aI?{Jr9H zLr9$R#)-#1{#o7r?-s_2&E2D^H#R<9PMt4X uMZo%6X1ED~qlx3D-Lny35YMi?st0S-BoRh-pmWFjlP z>>+Cu7JEAztJQE<0)?O&a-Bv%oIm)OlreYshv9+^f|BNTn<)@4uhFjQ-3j$T5cM+N z{(*fZyktuJVV4PAR%=q&n!QwLLDj^zntaat681uEZ3%m%RdbhZ>h?Nu|I DNBVd+T9OY1vbw<)&$Q?%7#4 Sc@fUiL;^M7H@MOA~IrEGqJ&PB97aj(b zG{6*7F&+AJF=0T2eEY4(%+v6LR!J==gKffiZ>h)t>$R>+x*T&@n@kc+46SAyt23<3 zZn@~~b|pD+nqa0bruOK_y)9WmzvCM4L`0gi_(w6woij(7oGdGANo2<=)9LNjg)Ygq z>=p|iJ1*c(z_Vgc)c#7(;c+7VC?oIMNh+7Mn?cKNx|N%Df@k#(-fRBMY5e26YZpEp z8roy_KQ1tbzD&JR3**_fU5*uA*5P~WlfGbovXPeS0pIVOP;#D7oXeg%pI^36NlQqx zM OA wcamlq3YG83Oqge86zd)Kf;fi+id{nxhq ;(&LRT($~(%d+f|)>8>-KuPweFlh39_MNVT)ZysPTI2?qU4GaykEHUOmU=%@M# zjoz%Gd(qXwqvw&?Ga= PJqpAQe+a_bF2d`hK{nrB39cBdj-UqSaDnO)u5I z7Ac%{A=&P-3f;QA{UFk0L*HnDh&;W_Q|Z!HF}Zo_#kY+;Wa^_`IttLjp`2eIK}S<| znF*e2PMzQy(Lu{IFxzbq_{r9wK>F!Z7EW0jrWoHG7qv;FFTIC1I1W`LLOo30E_!vo z?J#9_i>ek4K?T>jX6ld1 MT z)^HM4eRM=_PNx*o4Ao1(^zSNm?o^Auel1KJPE%CW)TE{LK(M|(T3AcD=?4V!eC300 z{^R4kwgm^+#l4G?qua4 OX>OQnLHKltd2+xIOXEViQn3ycx zeGfH?O(= &1xa{?(hJc`~3E?q;DMKbY{!`g@Q}KfWoO!}fSo%w$ccuC (3$2>)< z%vgq5 88
1hO3*p5A8S83-fHuLUY ezTYw9=QBR%!`og!krB66@KF%;W|!)KD}NHJ$Hi_v}_>?bMjWWrxb3xE@^>8 zTtt+O8NfYWe&UA%uOel{dg>UVz(&GZ;#YWMaM;X!veHQMJg9udQw||2CwWZp0=#0O zoj?GIW2I*Bh#B7F47%LK!ICa%JNd~mR8BWLg0d?_jHL`EzW1GEbhz%BL~R&`ud`&n z`tE@kV$zJ4i!bDZr7={Z&eID0+Upm5qR4@PIj%ydvO39EnlP={APObrO?gpcWTg2H zRhW>xCx>_G+-{+!hP|ZZ51{}m+3#EHY+tF|iufOPynGq+Uj13DgiDG=>Qg!Xjgvl= zg$kX9E&de|t!|UdvA{sYH%t_^eze)l!1{`v!s_rvgjQZU`%~K#_ASYT+9@-i_p9k} z&afg+C40wS??&pPh*N;c6F!y!5)*gyPk~f8&h5*yLh(J;U#vY9>Dpwnm##$0yCxlG z6eezvT2?!z>+Ah^9w2xA^P|BYXa_k4`H)i XiA?4<4vJg$WbY-75fj)rOwC8T;(Okg zOXlaVG#OjKUF~_wkZ7lj3|X^K+Bg%+$kd7bZ1>?l_JM !3kwGU25f)dhVaUK%4J0rXQNo_=u-b?gD}2 z;P;VsLG@p;25oeKNki0P)9WrZ2mwhl}z&SDczbf86NDPXZ%@s5WI~X-?Ef9!Glw z?dFYqZE;=O&<)93)(q;f^yxQ?Trvt!Twj;amRuSg-zk9{!tnmw+?fNs7-$QDsL9FL zF1_NT)xy|Gp-a?zLb?k5xB7Wrxm>v4DP(xGhvqBUsmxnX|1<)I)pg%h?*2(CFq|W3 zF<%(KJD{X5sN~`n|EK~#x6Yj6l;GJJrkxu~R-l0>(uHuyhnz2>r^ubo4~v(4wgBf* z_|$yUhF9}l?%1)iQ|kQ<6qp{`8a=f>*niNMY&iWn^eksGuFR9pS?-X%5}W`&rE;G0 zjC=@o1sy;EL|^Qc)Vn VX}D7DksJcrY{)pv|M8wpvbx&YVu^kS$kh< zFsfihZTa(0(;J4BsRPGDD6CL)ru_n6RY<=3I}CfZi@Ra@7UR1VQl1WwIq^x+I10BM z-23jjVJM|*pf}pq^FlI0jD|bZ58)ORfU~vmG~4&oaj2+zBYm}E;++$-j<#gfXLR_q z)@l={%rZ3V&p7h0r&VAb;Moq#PD=ycRk0UaF4US9b*3$jmPZQJzF*U1pV!21=6&Jb zN-$@8@jS%d68QDGqXkA$=W3mqr*DbfaCW}w%5b!q-s`SL*zZMOBR~D4{qbj(@pUJY zPG+5D8}Hc`8?06Iff<<0I?*nPKLw7owZU+kNx lsI|o{IPc8nqI;eXsEzEe`MMq<_-Xu-S+9Dtg zU$?TQ{#+eH9u{DCg{{Xc21 qNMXa;&4Ln7_i?4 zzf`QgcQhPxz>Jqo^v3GcsSfx4GHf(!UjUbPcv>Jx8*6NWOCenq42(jh8}_gpNScQE z%3~Gk9vWNcD@my}VZuWb#jhP!-p@?kiQ}^U%t(>xVV7XGA!tuWB|b8U^S%USv+s&n z=zp1Be2=0(AwM~(edQ%O%#(~pDrh?`KMftieE(3@WyN>hB~4pqEX3XHi$x);ZMxj{ z?Lk_&h(cH5H}R)0YYKjilLoX-W_CryJVbDMLqe5d?pyNQ5nr!$a^-RE8M|q-xz1k9 zKwOU?VQ^$m8kXSc>KCAn8R2U`UzuYs*)jT!B~!>gcd%B?B(c4}BZiRTCW8p3>akg@ zN%dpe6A^})^p&VFU)718t6y$sS{OW6fTI1A6a)|ug1YpB8tpzEmCxhUj{(x#haZWv zGu?QjX1kfyN!f2V51VV+r`}Cf*c#J&=M}9-fqu<3Y||^O#D1{5VL+K>H5MD=!du9j zaShO3kO-6ttS%Io(eJvxULBKD8(pr3kZ=lDa3<{cW^%tI;V@sNBr$X0t+OR&Qtb14 z;VCZqPcVBOtuNfY#n$QN74=hsL6%&+7raXK$Pze2WG^x~NP73n$nLI5vf#%q8ABW! zBzxsrCCTxFVD6uKNk5NQpQ|ICOpUe1y4aDHA?`Mw0R$#*dQR*G3F=3f(#xR>JL@h~ zEtZB?1%F7CFeX=hd29VJ{zp$1SLlGGec{xPab5a%vHjP!Ie+@5T(HnHE3~A`42#Yg z3mA8rk#-Wcqi4+T#>vl)3h8Q=_M;;)SM9mh58wN@P*~2grpQb0utq*~nEW=A-p%E+ z#OkV7QzIb4_I@tGd>Oj3m-(G!KaE2B_Z}H+5rdmA9e{hJI|R3I*+6^Rn+`d>#;Gg( z@mR*yqx-ld+;TW|#z9?r%lj$SIA8K!rf_uBQw^oB8M=EjQA~Sa(A8*2^PM~VWDkkE z4f<0lB$#t-VPe5%l`6iCL##(&C<>WF?F~2IBmcOWj=x3%5_I4J!juNSMzPY{%vgf) z&Mb0PZy0x*5@gLl)bJ$-gba=F!)-Wr@J>;&^)e&KDdihqJ{Z9WC=%!G!Yr7Py!;*W--v`va!Ffc>8*!JfS5hIMppF^}6 z4TPmiEeGfQdygLGQ>atik3@g21f5Bvn{lv!yrS$+hm?!6&DR%Bq71S;1$ sv2WvB{3b6`NuSkeK${9oyrE-0 zx*&3bl?SU#V3qlJy{5ht>diL?o&n9A0&!c&7YJMUKO|T@1p>k;P6#PZu*m%Yig}8^ z3M}}Cz>vR6>3Wda4jJQ`r>J1P+MdGdeY{+(v!cEK@_ky;D|S{U*Meoe9U0qP=nA#T zlY>MsXEik(6Mi7?Xn1&V!o?KnuTrRmfA+$=Q7O;4@`SVQdzH5 *L1 Q4|IgO|!RhG#0+OJJ=Kufz literal 0 HcmV?d00001 diff --git a/test_root/uploads/profile-images/f0d065035a5c4d32df318fbc54138765_120.jpg b/test_root/uploads/profile-images/f0d065035a5c4d32df318fbc54138765_120.jpg new file mode 100755 index 0000000000000000000000000000000000000000..4feac03ee91ef751e775583a823d84759b2bab46 GIT binary patch literal 3168 zcmbW2c{J327stP2Ft!XLlC2@T${N3z3E4xI#xfPMOekb&rj#kP7+YkC%Fx)xnq^4Q z=%-9+Q8Z&2yO6Dro#y*Yzu)uxp65J&{hsH3&i$OvIrpA>?(5w5`CPCU905dZEp03T z2m}BioCAO?z#QP?<%RL`@WEg(ettdyA@LnTf`UT3#dgBQWu@iiWTj C?GTy zWn@$jsc9b6*3;9IS1~d%)G^+#tEcm;2!x-XUr10$YR3*KoxL)9b^d1qTLDo%01x1y z5Cp&_3W173z?XnLr%xWp9|ru>AY4#x9$pw9zknbofhq!UL7-4BZYU2AH#aByDklzb zi}Hx=)i&kb>Er`LToBi}o>su8Xjbz=!uivJlJ1%CSbl+Bl2W^+_bIEWs;TSg8yqq; z`px{Pg{76XjjfC83ACHLho`Tfe*pGuU{FM4)I}Wr(&f1L8#iy=P9Ub=&B)BkzL%5x zh+J4yTvAFYtF5c2HZ(qIdis0Y%UA96*Kazyx<50RJ-vPXqhsS=CMKu8PR}eZ{a9XE zU0dJS{KW+UpnqaNiUKHLgH2S; z=lZ)|uNi6L;&P+7wk8kHXvj(qhs>rj>t-3xr9j~IvnUYo!h*mNPvFN!vu-7;G%)gQ zhI5#pvQcTa8ACR f9HHez%5Cv-pIJ5i%owc2qDLHeQl3IyCW2#9S*p%nfA zL*k)`uVwbuOi5x zAf)% zxAL V`axqBmG%Ps4W%!TO-4wAaJ%-Z>t0ZY!e6<>1 0~O^--x-L2n^ zXSLl(U{s7N-A#;l`V0bif@kjn>DJ9u|EU3mt#fUYo!fO@-#{Q4pRsTvrmkaX(6P8Y zXOy|a)#mV5@)X;8up!0zJUPXq|B^&l*TEOQvlez1R|U{$B+|a|;-wZ4NMuMACGBkm zfvogMlqp-^79}-Y9;ak5*V5{YI?2SY?&lyO56Q2+Aa6;!fB efh4`yi>xQ8GEB ziRz_T-$k80<+A3x#)) HYBzm=o+n_(bhV{DE`~g`58^>BGrKkkUe%8(mqW3Y-gq*&wx!J3kw{akF^47U) zIFr y=;PYEoIb^Lq!pt-PJ#;q;o^) zX^G^yG7S0i^1bJ3mi~zIYVnsw3i}jd5YKkVpYEo<7Ru87uFkvO>(f1hs 5hTIyQSohQ`wd$L_8anWOao*CT{2i_SI|xjzj LF~Bfm8V?Ags$YhS1m|Tjw|EIm(@DI6_LW%S*2% ztsv6Mv m=)*(bT{*?9K67@JI3K*4vh84RRq=aBB!=bPQK_dTmNlIvB@t*amynU+ za<_MpCj8ZTHhT=|JY=lb<(t1OH`x-zhiHg3+ 7KQN?O_|)Rt z#_*u`uU?`XmK+bq6ZHq)MGI`6l#}qG3}IG~D%y0r-ELX(0(~(>r!XfPKF!%P-WYyh zm@+!^%h5kxMTN?~6jmZy3{)H@E;)+KrqAYH&zSIb%#!L{sHUvxQ%1gXevp8Ht2gIC z;FEG_X2O{A$|gs84 z`BS6zCO$;OSf`K}O@=yqZ%S{ZxW;sA)(JZO*tcdPRH (g$JYSqP0aqPNe;00fb>_gl;DL>8&c3HTYPljFSgQ8y 6HTx6*2+KE5?%2_3D(a @tRvuyPDs+f(_~ zmANd=CR#GEnhfcCW_&$`%zU&cQ}cdIV*?Qt8F;T;?X_YrlXmUf`T3~mTTk)~U0HXe ztpxZ7OoFEZ&EPv EHkW literal 0 HcmV?d00001 diff --git a/test_root/uploads/profile-images/f0d065035a5c4d32df318fbc54138765_30.jpg b/test_root/uploads/profile-images/f0d065035a5c4d32df318fbc54138765_30.jpg new file mode 100755 index 0000000000000000000000000000000000000000..46be2ff684969c59713788fdae49c5527f0e8480 GIT binary patch literal 957 zcmex= ^(PF6}rMnOeST|r4lSw=>~TvNxu(8R< fW=16jCP7AKLB{__803NOWMu>c1}I=;VrF4wW9Q)H;sz?% zD!{ d!pzFb!U9xX3zTPI5o8roG<0MW4oqZMDikqloVbuf*=gfJ(V&YTRE(2~ znmD<{#3dx9RMpfqG__1j&CD$ #!_R-qH*cBs?~}BLh{ $iCku`%$hFJ%xfZZYGVpvf+4R}AS~Ydyw`->U%iVl4W!j-f|L#5rt;|(0jNUSd zVaI{zw-xy6woZPS>-Ics`<{PSvMU4C70r_selb)RA6##+X!pzHz4NttO2S|7{pa2o Uuk-C((Uh%ao3~wiyYl}{0C#V8<^TWy literal 0 HcmV?d00001 diff --git a/test_root/uploads/profile-images/f0d065035a5c4d32df318fbc54138765_50.jpg b/test_root/uploads/profile-images/f0d065035a5c4d32df318fbc54138765_50.jpg new file mode 100755 index 0000000000000000000000000000000000000000..a5ef6559b24ebf3d1208670903671509b96f9f70 GIT binary patch literal 1362 zcmex= ^(PF6}rMnOeST|r4lSw=>~TvNxu(8R< c1}I=;VrF4wW9Q)H;sz?% zD!{ d!pzFb!U9xX3zTPI5o8roG<0MW4oqZMDikqloVbuf*=gfJ(V&YTRE(2~ znmD<{#3dx9RMpfqG__1j&CD$ #!_R+R8Vi`Zw68q8_(eC|eS59msauaSuChEc zwdCDA=lOlpl85J2rEhdie$qX4ee!9I^>P0WTvXihIqkB-A+_g!Z>l$*XP)(_gmH=Q zqtz>(w^{0@>TNsubP}hI*#kaz(VO9nT17pns_*4Wc`k`Av~=UYYgwgz<3Gbg>5KI* z#B={M)Le}I&yW)OFZIWq`L9{|pVYVioU`&HTU-AVj)VQn9x~kiXTLOZ!^Il=^0l}3 zOcZN#Jap_v?~E_!YihO4-`o@V&oFQ0e};Ow?REzXa+P*x*JOTlF!NZDe2TgGxO(5; z?9-Fe?}y|qsnoWJd~^B6#husoh^K3Am}ITQc fRM}75g(=WKCd+CH{V;=j|dB^3=?7iF@92ZqgF56qb _~qe~z6N z3`~5VUH$EL{p*jP4VV0AUbbZ2>fEANoQ%g95?{N9PJVbLE!^-G&x(k*!Ezq5ZN;uB zPiNfEKXKPtecStwTJt$>nCweBZ_vH&f}{N0?95x+Ca15u9qGApOW4O{EQu#O1TRK> zk$-ObZ1?TFyT|2bF1y}#tCoMq>Yil}9{bJet}XxFAG70wdfU6S-$@=9o%|kc^jl c^Ubxo|o!jdGM&U-Sdr( zMY~#;T=1^gD8^`W;z_^*xh2B)@9mK1jdc2_mGSSYiZ)|epKAeM$j6fpp6%SFy=k3k z=oFR5AAZiO<=J|%*W*~d__X`as;A!qrkCe?;=g~W?-hNxB|QGy;eYe4=HH$h`j7Xu9czXBoJd-_{o$Q&lW@fG5`px2W hnOzJf$}h<)Ajp6A5Q2*rFOps+r6D7u;lIgnlmEZ{I&A %KgXJ+OWmi7*gPR=f_ZvL+V0)t+^366}4 zj){$XAD@6u&&bTm&dJUD^trUGyrQzIy0Ph7b4zPmdk3a}U~p)7WOQtLW_Iq^{KDeW z^49jw?%qE3;PB{7E&_n?53#`aKLq<1xhO%o&Jht25)q%tMR3j;EQFLq=Wp^|xGIAp zHnP2Ti{F<7axdaT$=8c40&1I7#&&&|uCoeGvu&M;_FJ<5JHh<^N0R+ruz$!k29OdG zfRjf^3BZ9vd@wZa+<(e{bnyS)8Xyz|P+Hst) pLfspx8ogL@u`Q$V^3&W9KDZoD>g=jeR KTf{Q?9AtJ#}{qVbRC5$B&%(7YkJmkY(`0J)7@w zf8I &7hUk zou5<5%zswkM(Q8Glqep?O+M-SFK#n6W W{GTZe%X(z3>_ %KKxJuXrVit%(7xA-`>W&=km!=k4`H7%85PR zgMOz_lP=VtMY&FJbuNUC%l*&h^uJd|23GI<*)bM(^JoEgAhLk`d*x&9h}6Hy4f%^e z5Y%J(^AMEuf4hKojWB&vjHQLn)_{B#7wRtmY;we@Ma6B)kci=+N#zu9IR#h@2Bti} zWF-LOQ ^eNy}jy}_V4mx;bhO`d> z0iK2QAplU#YQECvPWFz1x9?D$0&U%%_!GCq XLZZ{S?I@oOq;?^H^ nb3Z199w{{P|Z0_Zy GXUzJ$fc1I@&s{&GXmMaD+=S zUe;;@yOPgL$s=NREUxaKTBoN~=9LheZ|m|JW%@h-mfzjQr<#ABMqsq#uI9%*HqPK- z1aQGMV mdQ>xzOxj=sQ=>t|6VSg`4Dcwa1d7Om@@NgQvj3 zqgd} svqYcYP#1z*VhvGeMB}RR a`+Se?9`71o? z#!cUq72T~ pSzjm%|nlcC7q3-(@|n+-!ORyBGjRm7YsM_-o=Ou21H1H~Zu9AAkXgw|p1!(_9D z6g`3)`MPAw(pOvZv9Q)cm#Q!_-7y)ffg`;{KJSK{6L{OtZMR-mDId%`nK{QSY%%1> zDoAAu9n1j`DDGyYOZJ{i0p`Ojhz%_9QJLeGM}9iGzI9csDgIS?!7oNHsi`U}En2vk zG(}q3AAVwtwN0HA$;Gd^C8@R&Q8v?XK&;9L#2R+<+_YkB(jQ;eed0Qg#2@bE5AKd| zbAGs7H@YR30-LdX40n7^3|mA8uOVZ>$>jkyGxgqaj=P!8MRLBi;BO`5!qjxWpjRmi zV7b1+!ts5dVJL8Z?}TesgZ3CEnV@9weEJlarX1L|yVbT)U7Eh>SQ$!Mq0A7TM9@2| z=!*$1x?fGJ#U!z8pZ#?xc|C)8NghN3j@h`A_CswXLh6ClNpc`wJxPkZv=!$mpPH#z zxA`hqeO2u+Ej+27Mrv@V4|(ght2N_l!AlVXx&fXWJNi@uNk-&K&$?eoa+3B|lly#Z zcJrCdhO|!y|2n5a*XA>Kfss>>Ts&UTuwm6zpK5C2z)HC$slAKAv#ph?d+NY?yT^+2 zqUM_hWjUrjULGb>k1u76gMDuh64o?+ Ya~>IC5q^iF}P&v>f!SRK>AL@q@WQ>xMs;aHh= zrWpG!ubO%pX(kGz=?QJs+OQ#dG1Q#s1q5gMvaPPl=IpmiVZUzNghUc0Z7ms8>%aaA z&lDjPOGAQ2_~|lyXWG8?L;yam TMph^3T?;WtWWGH zKjnM=bdS=}OWHad9#GGv3*M5VE91Z iSTl!Kw)abH84dEiRBel z;Y3XIpD9p=LSc>Wv^zZU`&9-{N(2v|p3JGDgSmQOs%T&3%PGKI@40V*@>Cc2zY(PB z?5U6)Jma}ez})~_2`OP@Ve%`%u}|Od6=GDlYu}|R;_Xy4MI*{sbEI7r_w!J@G+|fD zOtkCHae~SK=%e>l5-vf}zGrG&f8rg5fM~a+_Wd?g=wK-0aRAuv2L@7v#c5c 2AU$2*Vs&1w^FcP#lSQjOH$U-y(+9EFLmOO1 zo_ttSo%IZtppH{7EO0Q3p+q?93 -DQ7EEc!zUxgxBhZce)Z+gzKMam^VsZfrR6XjVl$WLF?5*G@&tJ@GTdzhd_ z>p9UT6}|ez&JEcl*KfbPv(e#C*Wku9onKW}R`E$oSHk=!DPTm?vg$T4pboUs%-XCV zMfU{%5b#gN09g1ScBJYm>0^%exmjISQ72vDv^5?;1hq}B+AX5jKCqx)^JJQ}xTILn zn!9?v=IE}(Zsrpy_sa4V%#5P@>oKu(Q3va^0a&wpV8!$c0Yf7p4FC5W6zlzvRIR<@ zTSKLabrdKo-LeHSBqOUKO3n<869D0u ru;4or3m9i&Y@AWt>xaTAvFRjD4dK+d=qN_|lOBVFs zIqDMouyyC#p5ENfa2*eOw&WG^jY6v3HxqgX Na*Il8`Ye=`}R%jeu}Os9IENLO&rT)Nd1ks zIPO) P=FIxGA7jiSURCYm^{cxzrlwgiS) zM!lm;oqeTN$ PjXc!=x2a&kHjL2b*!juVy}j66#ZGfG82>$x@c z|MJJio1K>i7JxgjmmGE!jKn(`8_O{B;tT_1bNP<}H9HF^Iv@wsbb6uRR|E|N)z#1d zZnrs=#h2({fp>gEKA+bisHr2Av>r5Fvai?8*!S=IV(aS~@_tHSSgGnRT_y89ffWt2 zUWYX;{DigXBbvJtr6yg5>&J8@w&D%RUX~rsW}b7&6ZxD`TB3Xrp>m>*FwV;^FK*~s zxx7+ssYEcP;Ik()u()RX6sDoBWggz}4WN;4V@;p7ZP{f*ULuuCD=M2;3-qtTrta4N zhM~?dmHR%3!C^gO8yZ5%bp84zRuUh9Qy|vPIke7xX-#`EjokTbHEifjrv8{dGU!;= zzo4bCa@>YHuF|f>8F5Bz0M}175={S)ZRpW zd y^RjnsFXQqnuwUgI7ZqQ zia1K21=%hO%c+?g&}zJ&aQfumPBpKCeYGxld@wZN`ce7VcppAd3 $YcN>|h*~|Om7?F=b))(+Z;Z7gRgn4V{aM$;IC@+DyZr!0 zYalo8CCazmpf-QNk-dgGZRJ1>Bn (a|Q(y*Y^PoWfD~j5jA&mkrJ;PsNorHtHndoR?g~_w!WqwU`8_lJ(XbIT&lX zl^jBL!l*UPjpD-HMah*c&J0|hcxt9yLU?e|?Fp*1?evM5cb==-uYQ^Abt#L-oPV|_ z{ )mn7U%q6 z=H_Bph6~0z)g+u*2R<#Bi1(Pqt;C>XMte>^yuq8~8`5^jky+3XZOg3bw9mZYnYdT# zlSCRZS$h=6J{0!MLPg-4%<{S$S`iXjE*2@fQbum8Qp{Ukoie1te^E!Ig`dn}%=zuh z>s)h(RJL_eTT*)po;@lQiH+8S4&v3EqGrMKL<#Jq;k{vl>|Q^I5>sk*B5Uu(bNfND zbl%F!uod2I8-D%t80NwUzn&s=l3oWsCM|k|hjc)TwAOpX$|stU=Hqf1e%UvUhn>-u zCaBWG*E{b#X2&Ywg}VJVsVcSd!|VRX&3llU)$e+mRBB9S=)Q-+nh9(%uG+MaQ$S(r z!?fVgG=k}b_$72B++ddknx~UeXp(VXFs|YveM=%P3AYLhuszED*69#@XZ7tfi;E{4 zW5hK3Y2*i7ciYsEIgruEPeR%SNYWXoy@(eK_;L>Vb?WQfqpRIDpHpyN3!qANELx5$ zZ<$=iTYO+=W324v3c*r(8+TFJti^`|7cVI3Y|k-och_?s4l=YXFDq2P(+o1vnc9rD zdIh2yt+`ZeP9@WvjCvpuvm002ZqI9-mSoFbIhJa+ii_;Og36r5;h_$pde1GqI>lw_ zR79s~u;<>sUR+E&8T5MDbYq`oEDWa(po5`*Ed(G*R%lr0LSA+X+^pF+TA%6~uK&Y- zh!2DQqyEf&$eCW!QQ!c|oRtQO1Z%!km%oU-W4IrbiOMD?ck5S)8CT-q5*XQ4vi_m- z`P#RyrVR2Fj#86@nFh$|3G>B-*Ht5okDhz%U0&NTD7JDuzxGK_SB{CAmXt!pWW!L= zx0$o=SL{N|vnubwWqTR-g4kfx%GkSM*SYhIi)bI}Om?x3l)4 7`N&{^ z!~I!gv>8i13_1cM;?BuT9@HRLWFRoGpQ)#!KN-6Ipb(uTfsH;s)~XNjP$a7>9n-IR zY#MF+j5vVFf3kLNVROykx=~2YzQ$y-N?r&5y5`z)q1)wDRcIC+S8K^G+uOHQ>k9E% zW cymhZ41kQyp7_+Iskku3 z$GW%SH7R?2yRCJuLy1m9;;*zX_cB~@pRc2^J2b`Sgz(O^rZKLVOQi4$$8oQho4nD~ zdiLal?7T@IGPfAM#r#dws}mL*SvVhQA`rD5v!~O&l^XIrLsz+9uP`!oQX%_-`r6u> zo{ShHXOV5>_@tge_xyO*j(muXeN=Fe7$jPaz#akm_f-s`KM3|D#M(DD ~b>MT6nuz6<`%75GOK-4gRVA@u&@@}d1xv(#L z{VpQ
Ul>Isqjg$hEs)%K$)E88)o8B{Dc!e)n} zFHPev7YT-pISw|AU3fw<7@lQSDfyJ#Q^`}AuGXq4p80ZUqvB=t2;BR?VDa$m6@&xB z^E0mmBH}b7#VH6y=j1y#@&Vwf3G0>~_U&Fa#-Wd~G^awq)*l<=UCW8D$qS`cu c{98UT zR;=Lo7Dy8uE{q>d-K5arFi#^agE(dU#)9WF{>Dn`aB!Fdv_(zR>k%g(iW1XrJ^#dH zBD<0*kkf2OSmzer^a858WN(@EJTcOE`xL11AMzPxe3ov)^udhv0YOsC{(dnmR>V&> z7}3P>m1=2x?ivU!i;&G)Qkt0Yw8N*Tz~fa*(`&sATBTL2-<^V?1>uj+A_WKXpGOWy z{k4Wg2E5@WPa |Z$lTI=S|uVoo0)y9w8^H!gzy( zh^1PuMaX2p%=4r?KxO_Dx5|NFqe&x!QR~$7ZeZ3i_h?>rjt0F%xI4vrRHwvQdAw$M z-0vKh6a!*ja*8@ e2tDmU<$E&)y{A*F4D=^jE z+I^-6W(tzq%N~LDVVc>i=u-WA!O-G|o1no-lL2iFU_Q%Ixv_zOJaQJLU>vhFaMN$U z6K@)t8+Oi}SzS-Xa=#9#Trv3E0P{;ev`Rg-da`WpXy}bSdh$Ixk$;Dr_}#tY xE9!FU>ZkAC_=>d&wcs6We{2`%%XYE&y?UDA7Yxmnc!xq-+VL(?d7A5n z=VqHR$C1Flxea<+jwyEEHa8-4a(;q6SL_=AxT)U;ap&XumH49Xx?GHV9NCDbw(XYV zmdKbdTSpizWHao~nL6_fzYFLa($3$`{#0e9*k-MHpGHqqeTq0!JChMP ($;Bec09inQU zaV8{4X8ZvsU;>W~ZuNby%JPh7V14b}itl&3rc;lJ3v1%^RtPQ~e6-y*7GTkrkR*Z( zni%Ugv)fb-V7u>^mG-sSEhuCcIj|bLJVfRG-qp#lwsVoID_%~Ng@}RK^6 i5vyrOvuR$dtns&0LHtwt+gNMr4}_*ahr)=4OURLdrt+Q}-R@~M|YES~d6!!WC) z%EDJmJ>W4-iRjrZ5$)x!$y%r9aCWESJlRAG6|)N>@_V-)CgNcWU~v3GW#g=xbyn3c zwn_JH(zZyYe~4I*-)LJ zLox4xA$Y_?)q3Kdut$k`9{^H7K4&BUKLt;x!0+hkD}+N@FnapPIfIYOM5fo;v9lo} z^keSHTy`2$s!qCziPQ(D*rC3-h#tU+sceOg{0&pklc7%b_uE%65xuFFYhR7@Eml5B zj(0MA9N0ZbX|AEa{6ct;XHAvWQXK1TuT^)9@fPP=nxLE!h%c=V qT|6i zUL{p0C{;TOnfWZlW7jGlNn)*gRc@peON LPtD3efd(A1H< zu +mKTB`55Qsmxe^SeSJrmX!B&;e2{~m9D4uH*&n91@5w;Y5cXx+k zqcJ?jbES5vzt{~K$`*=tAiEaAFLGm;m(%Z?dWCugVTg-Po~L-qb&VsG{@zB@p=nLT zD(`IIM3^S)GPF1;_IjRE$vJH&P{@9-{8tdJ4eKEJn^0Gj0zwWx3UG*@Yn6fj{n; z{N)aM5tbnlhEDtfKU@Lrj_koHuuuS6L28+`o8yj OYNM@9J5Yid3i~D zaK1{FB9SAy@Dwne+^?{Nf7 nA!ihuJK1R*lAVzF)tSH+09S@E0Bnhpi3B- z`9-F966X${ZqLUEb`Cni%_P->E#fYHtRCpYdp7gi9AFdiRb{Yxr+Zb1Ct{={LOT1) zV`Ci_Y{}=SH~}#+p >EtR|5(w+9=iYaiYAB(=FlGUfiX{qz3)#v+F$ zmFi6xQ(`WQ`qv{!H6p6r4x(b~N6jo~va+Fn1D;GXn WAR+TwmBj3S^}JN8z3 zF7F5M*ezT1%qno$`XMvVC9-6oN+abcsT2i7j1-h~)zC=^a``#S!eg3I`ucZCq?WPv zO9rUIm1|Zy{(9d5JOqW~8qu7hvU*dvER&{o^YwEz6^f1Eh1Nr%9T%K#KCk9x`Wk;% zozaJ$J8$sB87}J0iXIUb!v*;-eYjP6@2-{&KbZ@gEp*Z0lO@gYqab3k8mbj5)?Lx! zd@Wh?T7x?xs-3WMuV}TNw)?nhqjyIjq$Ucx8=I_@pvEWbno(@{VFwf ilBMl+Ln_(7Kr`Ed)#B_MYg_ThUJkCsC;Pai(wHr?C2bSj@IRWORjL1PKZl<^ zBD(ba=#7}RUq5z=tvAba#(IKYtm{BRir1aUr`?AGgZC%`6QYN&Q|lPLd7w2_ca^+~ zz3%RdFWHV?SK*v+f *GysvU|hH~^up8od|uGWN`P^GWLip@2DzQ <5a__g3*)328Qu`h^&R2iZW#aI?{Jr9H zLr9$R#)-#1{#o7r?-s_2&E2D^H#R<9PMt4X uMZo%6X1ED~qlx3D-Lny35YMi?st0S-BoRh-pmWFjlP z>>+Cu7JEAztJQE<0)?O&a-Bv%oIm)OlreYshv9+^f|BNTn<)@4uhFjQ-3j$T5cM+N z{(*fZyktuJVV4PAR%=q&n!QwLLDj^zntaat681uEZ3%m%RdbhZ>h?Nu|I DNBVd+T9OY1vbw<)&$Q?%7#4 Sc@fUiL;^M7H@MOA~IrEGqJ&PB97aj(b zG{6*7F&+AJF=0T2eEY4(%+v6LR!J==gKffiZ>h)t>$R>+x*T&@n@kc+46SAyt23<3 zZn@~~b|pD+nqa0bruOK_y)9WmzvCM4L`0gi_(w6woij(7oGdGANo2<=)9LNjg)Ygq z>=p|iJ1*c(z_Vgc)c#7(;c+7VC?oIMNh+7Mn?cKNx|N%Df@k#(-fRBMY5e26YZpEp z8roy_KQ1tbzD&JR3**_fU5*uA*5P~WlfGbovXPeS0pIVOP;#D7oXeg%pI^36NlQqx zM OA wcamlq3YG83Oqge86zd)Kf;fi+id{nxhq ;(&LRT($~(%d+f|)>8>-KuPweFlh39_MNVT)ZysPTI2?qU4GaykEHUOmU=%@M# zjoz%Gd(qXwqvw&?Ga= PJqpAQe+a_bF2d`hK{nrB39cBdj-UqSaDnO)u5I z7Ac%{A=&P-3f;QA{UFk0L*HnDh&;W_Q|Z!HF}Zo_#kY+;Wa^_`IttLjp`2eIK}S<| znF*e2PMzQy(Lu{IFxzbq_{r9wK>F!Z7EW0jrWoHG7qv;FFTIC1I1W`LLOo30E_!vo z?J#9_i>ek4K?T>jX6ld1 MT z)^HM4eRM=_PNx*o4Ao1(^zSNm?o^Auel1KJPE%CW)TE{LK(M|(T3AcD=?4V!eC300 z{^R4kwgm^+#l4G?qua4 OX>OQnLHKltd2+xIOXEViQn3ycx zeGfH?O(= &1xa{?(hJc`~3E?q;DMKbY{!`g@Q}KfWoO!}fSo%w$ccuC (3$2>)< z%vgq5 88
1hO3*p5A8S83-fHuLUY ezTYw9=QBR%!`og!krB66@KF%;W|!)KD}NHJ$Hi_v}_>?bMjWWrxb3xE@^>8 zTtt+O8NfYWe&UA%uOel{dg>UVz(&GZ;#YWMaM;X!veHQMJg9udQw||2CwWZp0=#0O zoj?GIW2I*Bh#B7F47%LK!ICa%JNd~mR8BWLg0d?_jHL`EzW1GEbhz%BL~R&`ud`&n z`tE@kV$zJ4i!bDZr7={Z&eID0+Upm5qR4@PIj%ydvO39EnlP={APObrO?gpcWTg2H zRhW>xCx>_G+-{+!hP|ZZ51{}m+3#EHY+tF|iufOPynGq+Uj13DgiDG=>Qg!Xjgvl= zg$kX9E&de|t!|UdvA{sYH%t_^eze)l!1{`v!s_rvgjQZU`%~K#_ASYT+9@-i_p9k} z&afg+C40wS??&pPh*N;c6F!y!5)*gyPk~f8&h5*yLh(J;U#vY9>Dpwnm##$0yCxlG z6eezvT2?!z>+Ah^9w2xA^P|BYXa_k4`H)i XiA?4<4vJg$WbY-75fj)rOwC8T;(Okg zOXlaVG#OjKUF~_wkZ7lj3|X^K+Bg%+$kd7bZ1>?l_JM !3kwGU25f)dhVaUK%4J0rXQNo_=u-b?gD}2 z;P;VsLG@p;25oeKNki0P)9WrZ2mwhl}z&SDczbf86NDPXZ%@s5WI~X-?Ef9!Glw z?dFYqZE;=O&<)93)(q;f^yxQ?Trvt!Twj;amRuSg-zk9{!tnmw+?fNs7-$QDsL9FL zF1_NT)xy|Gp-a?zLb?k5xB7Wrxm>v4DP(xGhvqBUsmxnX|1<)I)pg%h?*2(CFq|W3 zF<%(KJD{X5sN~`n|EK~#x6Yj6l;GJJrkxu~R-l0>(uHuyhnz2>r^ubo4~v(4wgBf* z_|$yUhF9}l?%1)iQ|kQ<6qp{`8a=f>*niNMY&iWn^eksGuFR9pS?-X%5}W`&rE;G0 zjC=@o1sy;EL|^Qc)Vn VX}D7DksJcrY{)pv|M8wpvbx&YVu^kS$kh< zFsfihZTa(0(;J4BsRPGDD6CL)ru_n6RY<=3I}CfZi@Ra@7UR1VQl1WwIq^x+I10BM z-23jjVJM|*pf}pq^FlI0jD|bZ58)ORfU~vmG~4&oaj2+zBYm}E;++$-j<#gfXLR_q z)@l={%rZ3V&p7h0r&VAb;Moq#PD=ycRk0UaF4US9b*3$jmPZQJzF*U1pV!21=6&Jb zN-$@8@jS%d68QDGqXkA$=W3mqr*DbfaCW}w%5b!q-s`SL*zZMOBR~D4{qbj(@pUJY zPG+5D8}Hc`8?06Iff<<0I?*nPKLw7owZU+kNx lsI|o{IPc8nqI;eXsEzEe`MMq<_-Xu-S+9Dtg zU$?TQ{#+eH9u{DCg{{Xc21 qNMXa;&4Ln7_i?4 zzf`QgcQhPxz>Jqo^v3GcsSfx4GHf(!UjUbPcv>Jx8*6NWOCenq42(jh8}_gpNScQE z%3~Gk9vWNcD@my}VZuWb#jhP!-p@?kiQ}^U%t(>xVV7XGA!tuWB|b8U^S%USv+s&n z=zp1Be2=0(AwM~(edQ%O%#(~pDrh?`KMftieE(3@WyN>hB~4pqEX3XHi$x);ZMxj{ z?Lk_&h(cH5H}R)0YYKjilLoX-W_CryJVbDMLqe5d?pyNQ5nr!$a^-RE8M|q-xz1k9 zKwOU?VQ^$m8kXSc>KCAn8R2U`UzuYs*)jT!B~!>gcd%B?B(c4}BZiRTCW8p3>akg@ zN%dpe6A^})^p&VFU)718t6y$sS{OW6fTI1A6a)|ug1YpB8tpzEmCxhUj{(x#haZWv zGu?QjX1kfyN!f2V51VV+r`}Cf*c#J&=M}9-fqu<3Y||^O#D1{5VL+K>H5MD=!du9j zaShO3kO-6ttS%Io(eJvxULBKD8(pr3kZ=lDa3<{cW^%tI;V@sNBr$X0t+OR&Qtb14 z;VCZqPcVBOtuNfY#n$QN74=hsL6%&+7raXK$Pze2WG^x~NP73n$nLI5vf#%q8ABW! zBzxsrCCTxFVD6uKNk5NQpQ|ICOpUe1y4aDHA?`Mw0R$#*dQR*G3F=3f(#xR>JL@h~ zEtZB?1%F7CFeX=hd29VJ{zp$1SLlGGec{xPab5a%vHjP!Ie+@5T(HnHE3~A`42#Yg z3mA8rk#-Wcqi4+T#>vl)3h8Q=_M;;)SM9mh58wN@P*~2grpQb0utq*~nEW=A-p%E+ z#OkV7QzIb4_I@tGd>Oj3m-(G!KaE2B_Z}H+5rdmA9e{hJI|R3I*+6^Rn+`d>#;Gg( z@mR*yqx-ld+;TW|#z9?r%lj$SIA8K!rf_uBQw^oB8M=EjQA~Sa(A8*2^PM~VWDkkE z4f<0lB$#t-VPe5%l`6iCL##(&C<>WF?F~2IBmcOWj=x3%5_I4J!juNSMzPY{%vgf) z&Mb0PZy0x*5@gLl)bJ$-gba=F!)-Wr@J>;&^)e&KDdihqJ{Z9WC=%!G!Yr7Py!;*W--v`va!Ffc>8*!JfS5hIMppF^}6 z4TPmiEeGfQdygLGQ>atik3@g21f5Bvn{lv!yrS$+hm?!6&DR%Bq71S;1$ sv2WvB{3b6`NuSkeK${9oyrE-0 zx*&3bl?SU#V3qlJy{5ht>diL?o&n9A0&!c&7YJMUKO|T@1p>k;P6#PZu*m%Yig}8^ z3M}}Cz>vR6>3Wda4jJQ`r>J1P+MdGdeY{+(v!cEK@_ky;D|S{U*Meoe9U0qP=nA#T zlY>MsXEik(6Mi7?Xn1&V!o?KnuTrRmfA+$=Q7O;4@`SVQdzH5 *L1 Q4|IgO|!RhG#0+OJJ=Kufz literal 0 HcmV?d00001 From 87ac7c0600abe1bebad15e28640f7f8b20ec875f Mon Sep 17 00:00:00 2001 From: muzaffaryousaf Date: Thu, 26 Mar 2015 20:57:20 +0500 Subject: [PATCH 05/25] Learner profile page. TNL-1502 --- common/test/acceptance/pages/lms/dashboard.py | 6 + common/test/acceptance/pages/lms/fields.py | 88 ++++- .../acceptance/pages/lms/learner_profile.py | 151 +++++++++ .../tests/lms/test_learner_profile.py | 307 ++++++++++++++++++ lms/djangoapps/student_profile/__init__.py | 0 .../student_profile/test/__init__.py | 0 .../student_profile/test/test_views.py | 69 ++++ lms/djangoapps/student_profile/views.py | 71 ++++ lms/static/js/spec/main.js | 5 + .../account_settings_factory_spec.js | 8 +- lms/static/js/spec/student_account/helpers.js | 19 +- lms/static/js/spec/student_profile/helpers.js | 100 ++++++ .../learner_profile_factory_spec.js | 150 +++++++++ .../learner_profile_view_spec.js | 178 ++++++++++ lms/static/js/spec/views/fields_helpers.js | 58 +++- lms/static/js/spec/views/fields_spec.js | 123 ++++++- .../models/user_account_model.js | 19 +- .../views/learner_profile_factory.js | 131 ++++++++ .../views/learner_profile_fields.js | 38 +++ .../views/learner_profile_view.js | 71 ++++ lms/static/js/views/fields.js | 281 ++++++++++++++-- lms/static/sass/application-extend2.scss.mako | 1 + lms/static/sass/shared/_fields.scss | 54 ++- lms/static/sass/views/_learner-profile.scss | 199 ++++++++++++ .../fields/field_dropdown.underscore | 32 +- .../fields/field_textarea.underscore | 14 + lms/templates/navigation-edx.html | 3 +- lms/templates/navigation.html | 3 +- .../student_profile/learner_profile.html | 43 +++ .../learner_profile.underscore | 29 ++ lms/urls.py | 5 +- 31 files changed, 2178 insertions(+), 78 deletions(-) create mode 100644 common/test/acceptance/pages/lms/learner_profile.py create mode 100644 common/test/acceptance/tests/lms/test_learner_profile.py create mode 100644 lms/djangoapps/student_profile/__init__.py create mode 100644 lms/djangoapps/student_profile/test/__init__.py create mode 100644 lms/djangoapps/student_profile/test/test_views.py create mode 100644 lms/djangoapps/student_profile/views.py create mode 100644 lms/static/js/spec/student_profile/helpers.js create mode 100644 lms/static/js/spec/student_profile/learner_profile_factory_spec.js create mode 100644 lms/static/js/spec/student_profile/learner_profile_view_spec.js create mode 100644 lms/static/js/student_profile/views/learner_profile_factory.js create mode 100644 lms/static/js/student_profile/views/learner_profile_fields.js create mode 100644 lms/static/js/student_profile/views/learner_profile_view.js create mode 100644 lms/static/sass/views/_learner-profile.scss create mode 100644 lms/templates/fields/field_textarea.underscore create mode 100644 lms/templates/student_profile/learner_profile.html create mode 100644 lms/templates/student_profile/learner_profile.underscore diff --git a/common/test/acceptance/pages/lms/dashboard.py b/common/test/acceptance/pages/lms/dashboard.py index 10958a34d99c..29a20e1b05eb 100644 --- a/common/test/acceptance/pages/lms/dashboard.py +++ b/common/test/acceptance/pages/lms/dashboard.py @@ -202,3 +202,9 @@ def click_account_settings_link(self): Click on `Account Settings` link. """ self.q(css='.dropdown-menu li a').first.click() + + def click_my_profile_link(self): + """ + Click on `My Profile` link. + """ + self.q(css='.dropdown-menu li a').nth(1).click() diff --git a/common/test/acceptance/pages/lms/fields.py b/common/test/acceptance/pages/lms/fields.py index 62124ea4f168..7512937b84a6 100644 --- a/common/test/acceptance/pages/lms/fields.py +++ b/common/test/acceptance/pages/lms/fields.py @@ -30,6 +30,40 @@ def wait_for_field(self, field_id): "Field with id \"{0}\" is in DOM.".format(field_id) ).fulfill() + def mode_for_field(self, field_id): + """ + Extract current field mode. + + Returns: + `placeholder`/`edit`/`display` + """ + self.wait_for_field(field_id) + + query = self.q(css='.u-field-{}'.format(field_id)) + + if not query.present: + return None + + field_classes = query.attrs('class')[0].split() + + if 'mode-placeholder' in field_classes: + return 'placeholder' + + if 'mode-display' in field_classes: + return 'display' + + if 'mode-edit' in field_classes: + return 'edit' + + def icon_for_field(self, field_id, icon_id): + """ + Check if field icon is present. + """ + self.wait_for_field(field_id) + + query = self.q(css='.u-field-{} .u-field-icon'.format(field_id)) + return query.present and icon_id in query.attrs('class')[0].split() + def title_for_field(self, field_id): """ Return the title of a field. @@ -79,6 +113,23 @@ def wait_for_indicator(self, field_id, indicator): "Indicator \"{0}\" is visible.".format(self.indicator_for_field(field_id)) ).fulfill() + def make_field_editable(self, field_id): + """ + Make a field editable. + """ + query = self.q(css='.u-field-{}'.format(field_id)) + + if not query.present: + return None + + field_classes = query.attrs('class')[0].split() + + if 'mode-placeholder' in field_classes or 'mode-display' in field_classes: + if field_id == 'bio': + self.q(css='.u-field-bio > .wrapper-u-field').first.click() + else: + self.q(css='.u-field-{}'.format(field_id)).first.click() + def value_for_readonly_field(self, field_id): """ Return the value in a readonly field. @@ -104,19 +155,54 @@ def value_for_text_field(self, field_id, value=None): query.results[0].send_keys(u'\ue007') # Press Enter return query.attrs('value')[0] + def value_for_textarea_field(self, field_id, value=None): + """ + Get or set the value of a textarea field. + """ + self.wait_for_field(field_id) + + self.make_field_editable(field_id) + + query = self.q(css='.u-field-{} textarea'.format(field_id)) + if not query.present: + return None + + if value is not None: + query.fill(value) + query.results[0].send_keys(u'\ue004') # Focus Out using TAB + + if self.mode_for_field(field_id) == 'edit': + return query.text[0] + else: + return self.get_non_editable_mode_value(field_id) + + def get_non_editable_mode_value(self, field_id): + """ + Return value of field in `display` or `placeholder` mode. + """ + self.wait_for_field(field_id) + + return self.q(css='.u-field-{} .u-field-value'.format(field_id)).text[0] + def value_for_dropdown_field(self, field_id, value=None): """ Get or set the value in a dropdown field. """ self.wait_for_field(field_id) + self.make_field_editable(field_id) + query = self.q(css='.u-field-{} select'.format(field_id)) if not query.present: return None if value is not None: select_option_by_text(query, value) - return get_selected_option_text(query) + + if self.mode_for_field(field_id) == 'edit': + return get_selected_option_text(query) + else: + return self.get_non_editable_mode_value(field_id) def link_title_for_link_field(self, field_id): """ diff --git a/common/test/acceptance/pages/lms/learner_profile.py b/common/test/acceptance/pages/lms/learner_profile.py new file mode 100644 index 000000000000..a27c7b814482 --- /dev/null +++ b/common/test/acceptance/pages/lms/learner_profile.py @@ -0,0 +1,151 @@ +""" +Bok-Choy PageObject class for learner profile page. +""" +from . import BASE_URL +from bok_choy.page_object import PageObject +from .fields import FieldsMixin +from bok_choy.promise import EmptyPromise + + +PROFILE_VISIBILITY_SELECTOR = '#u-field-select-account_privacy option[value="{}"]' +FIELD_ICONS = { + 'country': 'fa-map-marker', + 'language_proficiencies': 'fa-comment', +} + + +class LearnerProfilePage(FieldsMixin, PageObject): + """ + PageObject methods for Learning Profile Page. + """ + + def __init__(self, browser, username): + """ + Initialize the page. + + Arguments: + browser (Browser): The browser instance. + username (str): Profile username. + """ + super(LearnerProfilePage, self).__init__(browser) + self.username = username + + @property + def url(self): + """ + Construct a URL to the page. + """ + return BASE_URL + "/u/" + self.username + + def is_browser_on_page(self): + """ + Check if browser is showing correct page. + """ + return 'Learner Profile' in self.browser.title + + @property + def privacy(self): + """ + Get user profile privacy. + + Returns: + 'all_users' or 'private' + """ + return 'all_users' if self.q(css=PROFILE_VISIBILITY_SELECTOR.format('all_users')).selected else 'private' + + @privacy.setter + def privacy(self, privacy): + """ + Set user profile privacy. + + Arguments: + privacy (str): 'all_users' or 'private' + """ + self.wait_for_element_visibility('select#u-field-select-account_privacy', 'Privacy dropdown is visiblie') + + if privacy != self.privacy: + self.q(css=PROFILE_VISIBILITY_SELECTOR.format(privacy)).first.click() + EmptyPromise(lambda: privacy == self.privacy, 'Privacy is set to {}'.format(privacy)).fulfill() + self.wait_for_ajax() + + if privacy == 'all_users': + self.wait_for_public_fields() + + def field_is_visible(self, field_id): + """ + Check if a field with id set to `field_id` is shown. + + Arguments: + field_id (str): field id + + Returns: + True/False + """ + self.wait_for_ajax() + return self.q(css='.u-field-{}'.format(field_id)).visible + + def field_is_editable(self, field_id): + """ + Check if a field with id set to `field_id` is editable. + + Arguments: + field_id (str): field id + + Returns: + True/False + """ + self.wait_for_field(field_id) + self.make_field_editable(field_id) + return self.mode_for_field(field_id) == 'edit' + + @property + def visible_fields(self): + """ + Return list of visible fields. + """ + self.wait_for_field('username') + + fields = ['username', 'country', 'language_proficiencies', 'bio'] + return [field for field in fields if self.field_is_visible(field)] + + @property + def editable_fields(self): + """ + Return list of editable fields currently shown on page. + """ + self.wait_for_ajax() + self.wait_for_element_visibility('.u-field-username', 'username is not visible') + + fields = ['country', 'language_proficiencies', 'bio'] + return [field for field in fields if self.field_is_editable(field)] + + @property + def privacy_field_visible(self): + """ + Check if profile visibility selector is shown or not. + + Returns: + True/False + """ + self.wait_for_ajax() + return self.q(css='#u-field-select-account_privacy').visible + + def field_icon_present(self, field_id): + """ + Check if an icon is present for a field. Only dropdown fields have icons. + + Arguments: + field_id (str): field id + + Returns: + True/False + """ + return self.icon_for_field(field_id, FIELD_ICONS[field_id]) + + def wait_for_public_fields(self): + """ + Wait for `country`, `language` and `bio` fields to be visible. + """ + EmptyPromise(lambda: self.field_is_visible('country'), 'Country field is visible').fulfill() + EmptyPromise(lambda: self.field_is_visible('language_proficiencies'), 'Language field is visible').fulfill() + EmptyPromise(lambda: self.field_is_visible('bio'), 'About Me field is visible').fulfill() diff --git a/common/test/acceptance/tests/lms/test_learner_profile.py b/common/test/acceptance/tests/lms/test_learner_profile.py new file mode 100644 index 000000000000..6fb13b689f8f --- /dev/null +++ b/common/test/acceptance/tests/lms/test_learner_profile.py @@ -0,0 +1,307 @@ +# -*- coding: utf-8 -*- +""" +End-to-end tests for Student's Profile Page. +""" + +from ...pages.lms.account_settings import AccountSettingsPage +from ...pages.lms.auto_auth import AutoAuthPage +from ...pages.lms.learner_profile import LearnerProfilePage +from ...pages.lms.dashboard import DashboardPage + +from bok_choy.web_app_test import WebAppTest + + +class LearnerProfilePageTest(WebAppTest): + """ + Tests that verify Student's Profile Page. + """ + + USER_1_NAME = 'user1' + USER_1_EMAIL = 'user1@edx.org' + USER_2_NAME = 'user2' + USER_2_EMAIL = 'user2@edx.org' + + MY_USER = 1 + OTHER_USER = 2 + + PRIVACY_PUBLIC = 'all_users' + PRIVACY_PRIVATE = 'private' + + PUBLIC_PROFILE_FIELDS = ['username', 'country', 'language_proficiencies', 'bio'] + PRIVATE_PROFILE_FIELDS = ['username'] + + PUBLIC_PROFILE_EDITABLE_FIELDS = ['country', 'language_proficiencies', 'bio'] + + def setUp(self): + """ + Initialize pages. + """ + super(LearnerProfilePageTest, self).setUp() + + self.account_settings_page = AccountSettingsPage(self.browser) + self.dashboard_page = DashboardPage(self.browser) + + self.my_auto_auth_page = AutoAuthPage(self.browser, username=self.USER_1_NAME, email=self.USER_1_EMAIL).visit() + self.my_profile_page = LearnerProfilePage(self.browser, self.USER_1_NAME) + + self.other_auto_auth_page = AutoAuthPage( + self.browser, + username=self.USER_2_NAME, + email=self.USER_2_EMAIL + ).visit() + + self.other_profile_page = LearnerProfilePage(self.browser, self.USER_2_NAME) + + def authenticate_as_user(self, user): + """ + Auto authenticate a user. + """ + if user == self.MY_USER: + self.my_auto_auth_page.visit() + elif user == self.OTHER_USER: + self.other_auto_auth_page.visit() + + def set_pubilc_profile_fields_data(self, profile_page): + """ + Fill in the public profile fields of a user. + """ + profile_page.value_for_dropdown_field('language_proficiencies', 'English') + profile_page.value_for_dropdown_field('country', 'United Kingdom') + profile_page.value_for_textarea_field('bio', 'Nothing Special') + + def visit_my_profile_page(self, user, privacy=None): + """ + Visits a users profile page. + """ + self.authenticate_as_user(user) + self.my_profile_page.visit() + self.my_profile_page.wait_for_page() + + if user is self.MY_USER and privacy is not None: + self.my_profile_page.privacy = privacy + + if privacy == self.PRIVACY_PUBLIC: + self.set_pubilc_profile_fields_data(self.my_profile_page) + + def visit_other_profile_page(self, user, privacy=None): + """ + Visits a users profile page. + """ + self.authenticate_as_user(user) + self.other_profile_page.visit() + self.other_profile_page.wait_for_page() + + if user is self.OTHER_USER and privacy is not None: + self.account_settings_page.visit() + self.account_settings_page.wait_for_page() + self.assertEqual(self.account_settings_page.value_for_dropdown_field('year_of_birth', '1980'), '1980') + + self.other_profile_page.visit() + self.other_profile_page.wait_for_page() + self.other_profile_page.privacy = privacy + + if privacy == self.PRIVACY_PUBLIC: + self.set_pubilc_profile_fields_data(self.other_profile_page) + + def test_dashboard_learner_profile_link(self): + """ + Scenario: Verify that my profile link is present on dashboard page and we can navigate to correct page. + + Given that I am a registered user. + When I go to Dashboard page. + And I click on username dropdown. + Then I see My Profile link in the dropdown menu. + When I click on My Profile link. + Then I will be navigated to My Profile page. + """ + self.dashboard_page.visit() + self.dashboard_page.click_username_dropdown() + self.assertTrue('My Profile' in self.dashboard_page.username_dropdown_link_text) + self.dashboard_page.click_my_profile_link() + self.my_profile_page.wait_for_page() + + def test_fields_on_my_private_profile(self): + """ + Scenario: Verify that desired fields are shown when looking at her own private profile. + + Given that I am a registered user. + And I visit My Profile page. + And I set the profile visibility to private. + And I reload the page. + Then I should see the profile visibility selector dropdown. + Then I see some of the profile fields are shown. + """ + self.visit_my_profile_page(self.MY_USER, privacy=self.PRIVACY_PRIVATE) + + self.assertTrue(self.my_profile_page.privacy_field_visible) + self.assertEqual(self.my_profile_page.visible_fields, self.PRIVATE_PROFILE_FIELDS) + + def test_fields_on_my_public_profile(self): + """ + Scenario: Verify that desired fields are shown when looking at her own public profile. + + Given that I am a registered user. + And I visit My Profile page. + And I set the profile visibility to public. + And I reload the page. + Then I should see the profile visibility selector dropdown. + Then I see all the profile fields are shown. + And `location`, `language` and `about me` fields are editable. + """ + self.visit_my_profile_page(self.MY_USER, privacy=self.PRIVACY_PUBLIC) + + self.assertTrue(self.my_profile_page.privacy_field_visible) + self.assertEqual(self.my_profile_page.visible_fields, self.PUBLIC_PROFILE_FIELDS) + + self.assertEqual(self.my_profile_page.editable_fields, self.PUBLIC_PROFILE_EDITABLE_FIELDS) + + def test_fields_on_others_private_profile(self): + """ + Scenario: Verify that desired fields are shown when looking at her own private profile. + + Given that I am a registered user. + And I visit others private profile page. + Then I shouldn't see the profile visibility selector dropdown. + Then I see some of the profile fields are shown. + """ + self.visit_other_profile_page(self.OTHER_USER, privacy=self.PRIVACY_PRIVATE) + self.visit_other_profile_page(self.MY_USER) + + self.assertFalse(self.other_profile_page.privacy_field_visible) + self.assertEqual(self.other_profile_page.visible_fields, self.PRIVATE_PROFILE_FIELDS) + + def test_fields_on_others_public_profile(self): + """ + Scenario: Verify that desired fields are shown when looking at her own public profile. + + Given that I am a registered user. + And I visit others public profile page. + Then I shouldn't see the profile visibility selector dropdown. + Then all the profile fields are shown. + Then I shouldn't see the profile visibility selector dropdown. + Also `location`, `language` and `about me` fields are not editable. + """ + self.visit_other_profile_page(self.OTHER_USER, privacy=self.PRIVACY_PUBLIC) + self.visit_other_profile_page(self.MY_USER) + + self.other_profile_page.wait_for_public_fields() + self.assertFalse(self.other_profile_page.privacy_field_visible) + + fields_to_check = self.PUBLIC_PROFILE_FIELDS + self.assertEqual(self.other_profile_page.visible_fields, fields_to_check) + + self.assertEqual(self.my_profile_page.editable_fields, []) + + def _test_dropdown_field(self, field_id, new_value, displayed_value, mode): + """ + Test behaviour of a dropdown field. + """ + self.visit_my_profile_page(self.MY_USER, privacy=self.PRIVACY_PUBLIC) + + self.my_profile_page.value_for_dropdown_field(field_id, new_value) + self.assertEqual(self.my_profile_page.get_non_editable_mode_value(field_id), displayed_value) + self.assertTrue(self.my_profile_page.mode_for_field(field_id), mode) + + self.browser.refresh() + self.my_profile_page.wait_for_page() + + self.assertEqual(self.my_profile_page.get_non_editable_mode_value(field_id), displayed_value) + self.assertTrue(self.my_profile_page.mode_for_field(field_id), mode) + + def _test_textarea_field(self, field_id, new_value, displayed_value, mode): + """ + Test behaviour of a textarea field. + """ + self.visit_my_profile_page(self.MY_USER, privacy=self.PRIVACY_PUBLIC) + + self.my_profile_page.value_for_textarea_field(field_id, new_value) + self.assertEqual(self.my_profile_page.get_non_editable_mode_value(field_id), displayed_value) + self.assertTrue(self.my_profile_page.mode_for_field(field_id), mode) + + self.browser.refresh() + self.my_profile_page.wait_for_page() + + self.assertEqual(self.my_profile_page.get_non_editable_mode_value(field_id), displayed_value) + self.assertTrue(self.my_profile_page.mode_for_field(field_id), mode) + + def test_country_field(self): + """ + Test behaviour of `Country` field. + + Given that I am a registered user. + And I visit My Profile page. + And I set the profile visibility to public and set default values for public fields. + Then I set country value to `Pakistan`. + Then displayed country should be `Pakistan` and country field mode should be `display` + And I reload the page. + Then displayed country should be `Pakistan` and country field mode should be `display` + And I make `country` field editable + Then `country` field mode should be `edit` + And `country` field icon should be visible. + """ + self._test_dropdown_field('country', 'Pakistan', 'Pakistan', 'display') + + self.my_profile_page.make_field_editable('country') + self.assertTrue(self.my_profile_page.mode_for_field('country'), 'edit') + + self.assertTrue(self.my_profile_page.field_icon_present('country')) + + def test_language_field(self): + """ + Test behaviour of `Language` field. + + Given that I am a registered user. + And I visit My Profile page. + And I set the profile visibility to public and set default values for public fields. + Then I set language value to `Urdu`. + Then displayed language should be `Urdu` and language field mode should be `display` + And I reload the page. + Then displayed language should be `Urdu` and language field mode should be `display` + Then I set empty value for language. + Then displayed language should be `Add language` and language field mode should be `placeholder` + And I reload the page. + Then displayed language should be `Add language` and language field mode should be `placeholder` + And I make `language` field editable + Then `language` field mode should be `edit` + And `language` field icon should be visible. + """ + self._test_dropdown_field('language_proficiencies', 'Urdu', 'Urdu', 'display') + self._test_dropdown_field('language_proficiencies', '', 'Add language', 'placeholder') + + self.my_profile_page.make_field_editable('language_proficiencies') + self.assertTrue(self.my_profile_page.mode_for_field('language_proficiencies'), 'edit') + + self.assertTrue(self.my_profile_page.field_icon_present('language_proficiencies')) + + def test_about_me_field(self): + """ + Test behaviour of `About Me` field. + + Given that I am a registered user. + And I visit My Profile page. + And I set the profile visibility to public and set default values for public fields. + Then I set about me value to `Eat Sleep Code`. + Then displayed about me should be `Eat Sleep Code` and about me field mode should be `display` + And I reload the page. + Then displayed about me should be `Eat Sleep Code` and about me field mode should be `display` + Then I set empty value for about me. + Then displayed about me should be `Tell other edX learners a little about yourself: where you live, + what your interests are, why you're taking courses on edX, or what you hope to learn.` and about me + field mode should be `placeholder` + And I reload the page. + Then displayed about me should be `Tell other edX learners a little about yourself: where you live, + what your interests are, why you're taking courses on edX, or what you hope to learn.` and about me + field mode should be `placeholder` + And I make `about me` field editable + Then `about me` field mode should be `edit` + """ + placeholder_value = ( + "Tell other edX learners a little about yourself: where you live, what your interests are, " + "why you're taking courses on edX, or what you hope to learn." + ) + + self._test_textarea_field('bio', 'Eat Sleep Code', 'Eat Sleep Code', 'display') + self._test_textarea_field('bio', '', placeholder_value, 'placeholder') + + self.my_profile_page.make_field_editable('bio') + self.assertTrue(self.my_profile_page.mode_for_field('bio'), 'edit') diff --git a/lms/djangoapps/student_profile/__init__.py b/lms/djangoapps/student_profile/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/lms/djangoapps/student_profile/test/__init__.py b/lms/djangoapps/student_profile/test/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/lms/djangoapps/student_profile/test/test_views.py b/lms/djangoapps/student_profile/test/test_views.py new file mode 100644 index 000000000000..e09e2c050179 --- /dev/null +++ b/lms/djangoapps/student_profile/test/test_views.py @@ -0,0 +1,69 @@ +# -*- coding: utf-8 -*- +""" Tests for student profile views. """ + +from django.conf import settings +from django.core.urlresolvers import reverse +from django.test import TestCase + +from util.testing import UrlResetMixin +from student.tests.factories import UserFactory + +from student_profile.views import learner_profile_context + + +class LearnerProfileViewTest(UrlResetMixin, TestCase): + """ Tests for the student profile view. """ + + USERNAME = "username" + PASSWORD = "password" + CONTEXT_DATA = [ + 'default_public_account_fields', + 'accounts_api_url', + 'preferences_api_url', + 'account_settings_page_url', + 'has_preferences_access', + 'own_profile', + 'country_options', + 'language_options', + ] + + def setUp(self): + super(LearnerProfileViewTest, self).setUp() + self.user = UserFactory.create(username=self.USERNAME, password=self.PASSWORD) + self.client.login(username=self.USERNAME, password=self.PASSWORD) + + def test_context(self): + """ + Verify learner profile page context data. + """ + context = learner_profile_context(self.user.username, self.USERNAME, self.user.is_staff) + + self.assertEqual( + context['data']['default_public_account_fields'], + settings.ACCOUNT_VISIBILITY_CONFIGURATION['public_fields'] + ) + + self.assertEqual( + context['data']['accounts_api_url'], + reverse("accounts_api", kwargs={'username': self.user.username}) + ) + + self.assertEqual( + context['data']['preferences_api_url'], + reverse('preferences_api', kwargs={'username': self.user.username}) + ) + + self.assertEqual(context['data']['account_settings_page_url'], reverse('account_settings')) + + for attribute in self.CONTEXT_DATA: + self.assertIn(attribute, context['data']) + + def test_view(self): + """ + Verify learner profile page view. + """ + profile_path = reverse('learner_profile', kwargs={'username': self.USERNAME}) + response = self.client.get(path=profile_path) + + for attribute in self.CONTEXT_DATA: + self.assertIn(attribute, response.content) diff --git a/lms/djangoapps/student_profile/views.py b/lms/djangoapps/student_profile/views.py new file mode 100644 index 000000000000..cd476583b953 --- /dev/null +++ b/lms/djangoapps/student_profile/views.py @@ -0,0 +1,71 @@ +""" Views for a student's profile information. """ + +from django.conf import settings +from django_countries import countries + +from django.core.urlresolvers import reverse +from django.contrib.auth.decorators import login_required +from django.views.decorators.http import require_http_methods + +from edxmako.shortcuts import render_to_response + + +@login_required +@require_http_methods(['GET']) +def learner_profile(request, username): + """ + Render the students profile page. + + Args: + request (HttpRequest) + username (str): username of user whose profile is requested. + + Returns: + HttpResponse: 200 if the page was sent successfully + HttpResponse: 302 if not logged in (redirect to login page) + HttpResponse: 405 if using an unsupported HTTP method + + Example usage: + GET /account/profile + """ + return render_to_response( + 'student_profile/learner_profile.html', + learner_profile_context(request.user.username, username, request.user.is_staff) + ) + + +def learner_profile_context(logged_in_username, profile_username, user_is_staff): + """ + Context for the learner profile page. + + Args: + logged_in_username (str): Username of user logged In user. + profile_username (str): username of user whose profile is requested. + user_is_staff (bool): Logged In user has staff access. + + Returns: + dict + """ + language_options = [language for language in settings.ALL_LANGUAGES] + + country_options = [ + (country_code, unicode(country_name)) + for country_code, country_name in sorted( + countries.countries, key=lambda(__, name): unicode(name) + ) + ] + + context = { + 'data': { + 'default_public_account_fields': settings.ACCOUNT_VISIBILITY_CONFIGURATION['public_fields'], + 'accounts_api_url': reverse("accounts_api", kwargs={'username': profile_username}), + 'preferences_api_url': reverse('preferences_api', kwargs={'username': profile_username}), + 'account_settings_page_url': reverse('account_settings'), + 'has_preferences_access': (logged_in_username == profile_username or user_is_staff), + 'own_profile': (logged_in_username == profile_username), + 'country_options': country_options, + 'language_options': language_options, + } + } + + return context diff --git a/lms/static/js/spec/main.js b/lms/static/js/spec/main.js index b68ba7a4fa75..8bebcf05ddc8 100644 --- a/lms/static/js/spec/main.js +++ b/lms/static/js/spec/main.js @@ -90,6 +90,9 @@ 'js/student_account/views/RegisterView': 'js/student_account/views/RegisterView', 'js/student_account/views/AccessView': 'js/student_account/views/AccessView', 'js/student_profile/profile': 'js/student_profile/profile', + 'js/student_profile/views/learner_profile_fields': 'js/student_profile/views/learner_profile_fields', + 'js/student_profile/views/learner_profile_factory': 'js/student_profile/views/learner_profile_factory', + 'js/student_profile/views/learner_profile_view': 'js/student_profile/views/learner_profile_view', // edxnotes 'annotator_1.2.9': 'xmodule_js/common_static/js/vendor/edxnotes/annotator-full.min' @@ -593,6 +596,8 @@ 'lms/include/js/spec/student_account/account_settings_view_spec.js', 'lms/include/js/spec/student_profile/profile_spec.js', 'lms/include/js/spec/views/fields_spec.js', + 'lms/include/js/spec/student_profile/learner_profile_factory_spec.js', + 'lms/include/js/spec/student_profile/learner_profile_view_spec.js', 'lms/include/js/spec/verify_student/pay_and_verify_view_spec.js', 'lms/include/js/spec/verify_student/webcam_photo_view_spec.js', 'lms/include/js/spec/verify_student/image_input_spec.js', diff --git a/lms/static/js/spec/student_account/account_settings_factory_spec.js b/lms/static/js/spec/student_account/account_settings_factory_spec.js index d927241e5354..4f993b1a5706 100644 --- a/lms/static/js/spec/student_account/account_settings_factory_spec.js +++ b/lms/static/js/spec/student_account/account_settings_factory_spec.js @@ -132,7 +132,7 @@ define(['backbone', 'jquery', 'underscore', 'js/common_helpers/ajax_helpers', 'j expect(sectionsData[0].fields.length).toBe(5); var textFields = [sectionsData[0].fields[1], sectionsData[0].fields[2]]; - for (var i = 0; i < textFields ; i++) { + for (var i = 0; i < textFields.length ; i++) { var view = textFields[i].view; FieldViewsSpecHelpers.verifyTextField(view, { @@ -154,9 +154,9 @@ define(['backbone', 'jquery', 'underscore', 'js/common_helpers/ajax_helpers', 'j title: view.options.title, valueAttribute: view.options.valueAttribute, helpMessage: '', - validValue: Helpers.FIELD_OPTIONS[0][0], - invalidValue1: Helpers.FIELD_OPTIONS[1][0], - invalidValue2: Helpers.FIELD_OPTIONS[2][0], + validValue: Helpers.FIELD_OPTIONS[1][0], + invalidValue1: Helpers.FIELD_OPTIONS[2][0], + invalidValue2: Helpers.FIELD_OPTIONS[3][0], validationError: "Nope, this will not do!" }, requests); } diff --git a/lms/static/js/spec/student_account/helpers.js b/lms/static/js/spec/student_account/helpers.js index a19bda2c526e..f110bcfcce64 100644 --- a/lms/static/js/spec/student_account/helpers.js +++ b/lms/static/js/spec/student_account/helpers.js @@ -9,11 +9,13 @@ define(['underscore'], function(_) { name: 'Student', email: 'student@edx.org', - level_of_education: '1', - gender: '2', - year_of_birth: '3', - country: '1', - language: '2' + level_of_education: '0', + gender: '0', + year_of_birth: '0', + country: '0', + language: '0', + bio: "About the student", + language_proficiencies: [{code: '1'}] }; var USER_PREFERENCES_DATA = { @@ -21,6 +23,7 @@ define(['underscore'], function(_) { }; var FIELD_OPTIONS = [ + ['0', 'Option 0'], ['1', 'Option 1'], ['2', 'Option 2'], ['3', 'Option 3'], @@ -28,9 +31,9 @@ define(['underscore'], function(_) { var expectLoadingIndicatorIsVisible = function (view, visible) { if (visible) { - expect(view.$('.ui-loading-indicator')).not.toHaveClass('is-hidden'); + expect($('.ui-loading-indicator')).not.toHaveClass('is-hidden'); } else { - expect(view.$('.ui-loading-indicator')).toHaveClass('is-hidden'); + expect($('.ui-loading-indicator')).toHaveClass('is-hidden'); } }; @@ -95,6 +98,6 @@ define(['underscore'], function(_) { expectLoadingErrorIsVisible: expectLoadingErrorIsVisible, expectElementContainsField: expectElementContainsField, expectSettingsSectionsButNotFieldsToBeRendered: expectSettingsSectionsButNotFieldsToBeRendered, - expectSettingsSectionsAndFieldsToBeRendered: expectSettingsSectionsAndFieldsToBeRendered + expectSettingsSectionsAndFieldsToBeRendered: expectSettingsSectionsAndFieldsToBeRendered, }; }); diff --git a/lms/static/js/spec/student_profile/helpers.js b/lms/static/js/spec/student_profile/helpers.js new file mode 100644 index 000000000000..777061d69c13 --- /dev/null +++ b/lms/static/js/spec/student_profile/helpers.js @@ -0,0 +1,100 @@ +define(['underscore'], function(_) { + 'use strict'; + + var expectProfileElementContainsField = function(element, view) { + var $element = $(element); + var fieldTitle = $element.find('.u-field-title').text().trim(); + + if (!_.isUndefined(view.options.title)) { + expect(fieldTitle).toBe(view.options.title); + } + + if ('fieldValue' in view) { + expect(view.model.get(view.options.valueAttribute)).toBeTruthy(); + + if (view.fieldValue()) { + expect(view.fieldValue()).toBe(view.modelValue()); + + } else if ('optionForValue' in view) { + expect($($element.find('.u-field-value')[0]).text()).toBe(view.displayValue(view.modelValue())); + + }else { + expect($($element.find('.u-field-value')[0]).text()).toBe(view.modelValue()); + } + } else { + throw new Error('Unexpected field type: ' + view.fieldType); + } + }; + + var expectProfilePrivacyFieldTobeRendered = function(learnerProfileView, othersProfile) { + + var accountPrivacyElement = learnerProfileView.$('.wrapper-profile-field-account-privacy'); + var privacyFieldElement = $(accountPrivacyElement).find('.u-field'); + + if (othersProfile) { + expect(privacyFieldElement.length).toBe(0); + } else { + expect(privacyFieldElement.length).toBe(1); + expectProfileElementContainsField(privacyFieldElement, learnerProfileView.options.accountPrivacyFieldView) + } + }; + + var expectSectionOneTobeRendered = function(learnerProfileView) { + + var sectionOneFieldElements = $(learnerProfileView.$('.wrapper-profile-section-one')).find('.u-field'); + + expect(sectionOneFieldElements.length).toBe(learnerProfileView.options.sectionOneFieldViews.length); + + _.each(sectionOneFieldElements, function (sectionFieldElement, fieldIndex) { + expectProfileElementContainsField(sectionFieldElement, learnerProfileView.options.sectionOneFieldViews[fieldIndex]); + }); + }; + + var expectSectionTwoTobeRendered = function(learnerProfileView) { + + var sectionTwoElement = learnerProfileView.$('.wrapper-profile-section-two'); + var sectionTwoFieldElements = $(sectionTwoElement).find('.u-field'); + + expect(sectionTwoFieldElements.length).toBe(learnerProfileView.options.sectionTwoFieldViews.length); + + _.each(sectionTwoFieldElements, function (sectionFieldElement, fieldIndex) { + expectProfileElementContainsField(sectionFieldElement, learnerProfileView.options.sectionTwoFieldViews[fieldIndex]); + }); + }; + + var expectProfileSectionsAndFieldsToBeRendered = function (learnerProfileView, othersProfile) { + expectProfilePrivacyFieldTobeRendered(learnerProfileView, othersProfile); + expectSectionOneTobeRendered(learnerProfileView); + expectSectionTwoTobeRendered(learnerProfileView); + }; + + var expectLimitedProfileSectionsAndFieldsToBeRendered = function (learnerProfileView, othersProfile) { + expectProfilePrivacyFieldTobeRendered(learnerProfileView, othersProfile); + + var sectionOneFieldElements = $(learnerProfileView.$('.wrapper-profile-section-one')).find('.u-field'); + + expect(sectionOneFieldElements.length).toBe(1); + _.each(sectionOneFieldElements, function (sectionFieldElement, fieldIndex) { + expectProfileElementContainsField(sectionFieldElement, learnerProfileView.options.sectionOneFieldViews[fieldIndex]); + }); + + if (othersProfile) { + expect($('.profile-private--message').text()).toBe('This edX learner is currently sharing a limited profile.') + } else { + expect($('.profile-private--message').text()).toBe('You are currently sharing a limited profile.') + } + }; + + var expectProfileSectionsNotToBeRendered = function(learnerProfileView) { + expect(learnerProfileView.$('.wrapper-profile-field-account-privacy').length).toBe(0); + expect(learnerProfileView.$('.wrapper-profile-section-one').length).toBe(0); + expect(learnerProfileView.$('.wrapper-profile-section-two').length).toBe(0); + }; + + return { + expectLimitedProfileSectionsAndFieldsToBeRendered: expectLimitedProfileSectionsAndFieldsToBeRendered, + expectProfileSectionsAndFieldsToBeRendered: expectProfileSectionsAndFieldsToBeRendered, + expectProfileSectionsNotToBeRendered: expectProfileSectionsNotToBeRendered + + }; +}); diff --git a/lms/static/js/spec/student_profile/learner_profile_factory_spec.js b/lms/static/js/spec/student_profile/learner_profile_factory_spec.js new file mode 100644 index 000000000000..79807d5ea304 --- /dev/null +++ b/lms/static/js/spec/student_profile/learner_profile_factory_spec.js @@ -0,0 +1,150 @@ +define(['backbone', 'jquery', 'underscore', 'js/common_helpers/ajax_helpers', 'js/common_helpers/template_helpers', + 'js/spec/student_account/helpers', + 'js/spec/student_profile/helpers', + 'js/views/fields', + 'js/student_account/models/user_account_model', + 'js/student_account/models/user_preferences_model', + 'js/student_profile/views/learner_profile_view', + 'js/student_profile/views/learner_profile_fields', + 'js/student_profile/views/learner_profile_factory' + ], + function (Backbone, $, _, AjaxHelpers, TemplateHelpers, Helpers, LearnerProfileHelpers, FieldViews, UserAccountModel, UserPreferencesModel, + LearnerProfileView, LearnerProfileFields, LearnerProfilePage) { + 'use strict'; + + describe("edx.user.LearnerProfileFactory", function () { + + var requests; + + beforeEach(function () { + setFixtures(' '); + TemplateHelpers.installTemplate('templates/fields/field_readonly'); + TemplateHelpers.installTemplate('templates/fields/field_dropdown'); + TemplateHelpers.installTemplate('templates/fields/field_textarea'); + TemplateHelpers.installTemplate('templates/student_profile/learner_profile'); + }); + + it("show loading error when UserAccountModel fails to load", function() { + + requests = AjaxHelpers.requests(this); + + var context = LearnerProfilePage({ + 'accounts_api_url': Helpers.USER_ACCOUNTS_API_URL, + 'preferences_api_url': Helpers.USER_PREFERENCES_API_URL, + 'own_profile': true, + 'account_settings_page_url': Helpers.USER_ACCOUNTS_API_URL, + 'country_options': Helpers.FIELD_OPTIONS, + 'language_options': Helpers.FIELD_OPTIONS, + 'has_preferences_access': true + }), + learnerProfileView = context.learnerProfileView; + + Helpers.expectLoadingIndicatorIsVisible(learnerProfileView, true); + Helpers.expectLoadingErrorIsVisible(learnerProfileView, false); + LearnerProfileHelpers.expectProfileSectionsNotToBeRendered(learnerProfileView); + + + var userAccountRequest = requests[0]; + expect(userAccountRequest.method).toBe('GET'); + expect(userAccountRequest.url).toBe(Helpers.USER_ACCOUNTS_API_URL); + + AjaxHelpers.respondWithError(requests, 500); + + Helpers.expectLoadingErrorIsVisible(learnerProfileView, true); + Helpers.expectLoadingIndicatorIsVisible(learnerProfileView, false); + LearnerProfileHelpers.expectProfileSectionsNotToBeRendered(learnerProfileView); + }); + + it("shows loading error when UserPreferencesModel fails to load", function() { + + requests = AjaxHelpers.requests(this); + + var context = LearnerProfilePage({ + 'accounts_api_url': Helpers.USER_ACCOUNTS_API_URL, + 'preferences_api_url': Helpers.USER_PREFERENCES_API_URL, + 'own_profile': true, + 'account_settings_page_url': Helpers.USER_ACCOUNTS_API_URL, + 'country_options': Helpers.FIELD_OPTIONS, + 'language_options': Helpers.FIELD_OPTIONS, + 'has_preferences_access': true + }), + learnerProfileView = context.learnerProfileView; + + Helpers.expectLoadingIndicatorIsVisible(learnerProfileView, true); + Helpers.expectLoadingErrorIsVisible(learnerProfileView, false); + LearnerProfileHelpers.expectProfileSectionsNotToBeRendered(learnerProfileView); + + var userAccountRequest = requests[0]; + expect(userAccountRequest.method).toBe('GET'); + expect(userAccountRequest.url).toBe(Helpers.USER_ACCOUNTS_API_URL); + + AjaxHelpers.respondWithJson(requests, Helpers.USER_ACCOUNTS_DATA); + Helpers.expectLoadingIndicatorIsVisible(learnerProfileView, true); + Helpers.expectLoadingErrorIsVisible(learnerProfileView, false); + LearnerProfileHelpers.expectProfileSectionsNotToBeRendered(learnerProfileView); + + var userPreferencesRequest = requests[1]; + expect(userPreferencesRequest.method).toBe('GET'); + expect(userPreferencesRequest.url).toBe(Helpers.USER_PREFERENCES_API_URL); + + AjaxHelpers.respondWithError(requests, 500); + Helpers.expectLoadingIndicatorIsVisible(learnerProfileView, false); + Helpers.expectLoadingErrorIsVisible(learnerProfileView, true); + LearnerProfileHelpers.expectProfileSectionsNotToBeRendered(learnerProfileView); + }); + + it("renders the limited profile after models are successfully fetched", function() { + + requests = AjaxHelpers.requests(this); + + var context = LearnerProfilePage({ + 'accounts_api_url': Helpers.USER_ACCOUNTS_API_URL, + 'preferences_api_url': Helpers.USER_PREFERENCES_API_URL, + 'own_profile': true, + 'account_settings_page_url': Helpers.USER_ACCOUNTS_API_URL, + 'country_options': Helpers.FIELD_OPTIONS, + 'language_options': Helpers.FIELD_OPTIONS, + 'has_preferences_access': true + }); + + var learnerProfileView = context.learnerProfileView; + + Helpers.expectLoadingIndicatorIsVisible(learnerProfileView, true); + Helpers.expectLoadingErrorIsVisible(learnerProfileView, false); + LearnerProfileHelpers.expectProfileSectionsNotToBeRendered(learnerProfileView); + + AjaxHelpers.respondWithJson(requests, Helpers.USER_ACCOUNTS_DATA); + AjaxHelpers.respondWithJson(requests, Helpers.USER_PREFERENCES_DATA); + + Helpers.expectLoadingErrorIsVisible(learnerProfileView, false); + LearnerProfileHelpers.expectLimitedProfileSectionsAndFieldsToBeRendered(learnerProfileView) + }); + + it("renders the full profile after models are successfully fetched", function() { + + requests = AjaxHelpers.requests(this); + + var context = LearnerProfilePage({ + 'accounts_api_url': Helpers.USER_ACCOUNTS_API_URL, + 'preferences_api_url': Helpers.USER_PREFERENCES_API_URL, + 'own_profile': true, + 'account_settings_page_url': Helpers.USER_ACCOUNTS_API_URL, + 'country_options': Helpers.FIELD_OPTIONS, + 'language_options': Helpers.FIELD_OPTIONS, + 'has_preferences_access': true + }), + learnerProfileView = context.learnerProfileView; + + Helpers.expectLoadingIndicatorIsVisible(learnerProfileView, true); + Helpers.expectLoadingErrorIsVisible(learnerProfileView, false); + LearnerProfileHelpers.expectProfileSectionsNotToBeRendered(learnerProfileView); + + AjaxHelpers.respondWithJson(requests, Helpers.USER_ACCOUNTS_DATA); + AjaxHelpers.respondWithJson(requests, Helpers.USER_PREFERENCES_DATA); + + // sets the profile for full view. + context.accountPreferencesModel.set({account_privacy: 'all_users'}); + LearnerProfileHelpers.expectProfileSectionsAndFieldsToBeRendered(learnerProfileView, false) + }); + }); + }); diff --git a/lms/static/js/spec/student_profile/learner_profile_view_spec.js b/lms/static/js/spec/student_profile/learner_profile_view_spec.js new file mode 100644 index 000000000000..484501d9b255 --- /dev/null +++ b/lms/static/js/spec/student_profile/learner_profile_view_spec.js @@ -0,0 +1,178 @@ +define(['backbone', 'jquery', 'underscore', 'js/common_helpers/ajax_helpers', 'js/common_helpers/template_helpers', + 'js/spec/student_account/helpers', + 'js/spec/student_profile/helpers', + 'js/views/fields', + 'js/student_account/models/user_account_model', + 'js/student_account/models/user_preferences_model', + 'js/student_profile/views/learner_profile_fields', + 'js/student_profile/views/learner_profile_view', + 'js/student_account/views/account_settings_fields' + ], + function (Backbone, $, _, AjaxHelpers, TemplateHelpers, Helpers, LearnerProfileHelpers, FieldViews, UserAccountModel, + AccountPreferencesModel, LearnerProfileFields, LearnerProfileView, AccountSettingsFieldViews) { + 'use strict'; + + describe("edx.user.LearnerProfileView", function (options) { + + var createLearnerProfileView = function (ownProfile, accountPrivacy, profileIsPublic) { + + var accountSettingsModel = new UserAccountModel(); + accountSettingsModel.set(Helpers.USER_ACCOUNTS_DATA); + accountSettingsModel.set({'profile_is_public': profileIsPublic}); + + var accountPreferencesModel = new AccountPreferencesModel(); + accountPreferencesModel.set({account_privacy: accountPrivacy}); + + accountPreferencesModel.url = Helpers.USER_PREFERENCES_API_URL; + + var editable = ownProfile ? 'toggle' : 'never'; + + var accountPrivacyFieldView = new LearnerProfileFields.AccountPrivacyFieldView({ + model: accountPreferencesModel, + required: true, + editable: 'always', + showMessages: false, + title: 'edX learners can see my:', + valueAttribute: "account_privacy", + options: [ + ['all_users', 'Full Profile'], + ['private', 'Limited Profile'] + ], + helpMessage: '', + accountSettingsPageUrl: '/account/settings/' + }); + + var usernameFieldView = new FieldViews.ReadonlyFieldView({ + model: accountSettingsModel, + valueAttribute: "username", + helpMessage: "" + }); + + var sectionOneFieldViews = [ + usernameFieldView, + new FieldViews.DropdownFieldView({ + model: accountSettingsModel, + required: false, + editable: editable, + showMessages: false, + iconName: 'fa-map-marker', + placeholderValue: 'Add country', + valueAttribute: "country", + options: Helpers.FIELD_OPTIONS, + helpMessage: '' + }), + + new AccountSettingsFieldViews.LanguageProficienciesFieldView({ + model: accountSettingsModel, + required: false, + editable: editable, + showMessages: false, + iconName: 'fa-comment', + placeholderValue: 'Add language', + valueAttribute: "language_proficiencies", + options: Helpers.FIELD_OPTIONS, + helpMessage: '' + }) + ]; + + var sectionTwoFieldViews = [ + new FieldViews.TextareaFieldView({ + model: accountSettingsModel, + editable: editable, + showMessages: false, + title: 'About me', + placeholderValue: "Tell other edX learners a little about yourself: where you live, what your interests are, why you're taking courses on edX, or what you hope to learn.", + valueAttribute: "bio", + helpMessage: '' + }) + ]; + + return new LearnerProfileView( + { + el: $('.wrapper-profile'), + own_profile: ownProfile, + has_preferences_access: true, + accountSettingsModel: accountSettingsModel, + preferencesModel: accountPreferencesModel, + accountPrivacyFieldView: accountPrivacyFieldView, + usernameFieldView: usernameFieldView, + sectionOneFieldViews: sectionOneFieldViews, + sectionTwoFieldViews: sectionTwoFieldViews + }); + + }; + + beforeEach(function () { + setFixtures('Loading
An error occurred. Please reload the page.'); + TemplateHelpers.installTemplate('templates/fields/field_readonly'); + TemplateHelpers.installTemplate('templates/fields/field_dropdown'); + TemplateHelpers.installTemplate('templates/fields/field_textarea'); + TemplateHelpers.installTemplate('templates/student_profile/learner_profile'); + }); + + it("shows loading error correctly", function() { + + var learnerProfileView = createLearnerProfileView(false, 'all_users'); + + Helpers.expectLoadingIndicatorIsVisible(learnerProfileView, true); + Helpers.expectLoadingErrorIsVisible(learnerProfileView, false); + + learnerProfileView.render(); + learnerProfileView.showLoadingError(); + + Helpers.expectLoadingErrorIsVisible(learnerProfileView, true); + }); + + it("renders all fields as expected for self with full access", function() { + + var learnerProfileView = createLearnerProfileView(true, 'all_users', true); + + Helpers.expectLoadingIndicatorIsVisible(learnerProfileView, true); + Helpers.expectLoadingErrorIsVisible(learnerProfileView, false); + + learnerProfileView.render(); + + Helpers.expectLoadingErrorIsVisible(learnerProfileView, false); + LearnerProfileHelpers.expectProfileSectionsAndFieldsToBeRendered(learnerProfileView); + }); + + it("renders all fields as expected for self with limited access", function() { + + var learnerProfileView = createLearnerProfileView(true, 'private', false); + + Helpers.expectLoadingIndicatorIsVisible(learnerProfileView, true); + Helpers.expectLoadingErrorIsVisible(learnerProfileView, false); + + learnerProfileView.render(); + + Helpers.expectLoadingErrorIsVisible(learnerProfileView, false); + LearnerProfileHelpers.expectLimitedProfileSectionsAndFieldsToBeRendered(learnerProfileView); + }); + + it("renders the fields as expected for others with full access", function() { + + var learnerProfileView = createLearnerProfileView(false, 'all_users', true); + + Helpers.expectLoadingIndicatorIsVisible(learnerProfileView, true); + Helpers.expectLoadingErrorIsVisible(learnerProfileView, false); + + learnerProfileView.render(); + + Helpers.expectLoadingErrorIsVisible(learnerProfileView, false); + LearnerProfileHelpers.expectProfileSectionsAndFieldsToBeRendered(learnerProfileView, true) + }); + + it("renders the fields as expected for others with limited access", function() { + + var learnerProfileView = createLearnerProfileView(false, 'private', false); + + Helpers.expectLoadingIndicatorIsVisible(learnerProfileView, true); + Helpers.expectLoadingErrorIsVisible(learnerProfileView, false); + + learnerProfileView.render(); + + Helpers.expectLoadingErrorIsVisible(learnerProfileView, false); + LearnerProfileHelpers.expectLimitedProfileSectionsAndFieldsToBeRendered(learnerProfileView, true); + }); + }); + }); diff --git a/lms/static/js/spec/views/fields_helpers.js b/lms/static/js/spec/views/fields_helpers.js index a1f4e1dc0baa..f7993cb2f648 100644 --- a/lms/static/js/spec/views/fields_helpers.js +++ b/lms/static/js/spec/views/fields_helpers.js @@ -27,7 +27,8 @@ define(['backbone', 'jquery', 'underscore', 'js/common_helpers/ajax_helpers', 'j model: fieldData.model || new UserAccountModel({}), title: fieldData.title || 'Field Title', valueAttribute: fieldData.valueAttribute, - helpMessage: fieldData.helpMessage || 'I am a field message' + helpMessage: fieldData.helpMessage || 'I am a field message', + placeholderValue: fieldData.placeholderValue || 'I am a placeholder message' }; switch (fieldType) { @@ -58,8 +59,12 @@ define(['backbone', 'jquery', 'underscore', 'js/common_helpers/ajax_helpers', 'j } }; - var expectTitleAndMessageToBe = function(view, expectedTitle, expectedMessage) { + var expectTitleToBe = function(view, expectedTitle) { expect(view.$('.u-field-title').text().trim()).toBe(expectedTitle); + }; + + var expectTitleAndMessageToBe = function(view, expectedTitle, expectedMessage) { + expectTitleToBe(view, expectedTitle); expect(view.$('.u-field-message').text().trim()).toBe(expectedMessage); }; @@ -125,9 +130,19 @@ define(['backbone', 'jquery', 'underscore', 'js/common_helpers/ajax_helpers', 'j var request_data = {}; var url = view.model.url; - expectTitleAndMessageToBe(view, data.title, data.helpMessage); + if (data.editable === 'toggle') { + expect(view.el).toHaveClass('mode-placeholder'); + expectTitleToBe(view, data.title); + expectMessageContains(view, view.indicators['canEdit']); + view.$el.click(); + } else { + expectTitleAndMessageToBe(view, data.title, data.helpMessage); + } + + expect(view.el).toHaveClass('mode-edit'); + expect(view.fieldValue()).not.toBe(data.validValue); - view.$(data.valueElementSelector).val(data.validValue).change(); + view.$(data.valueInputSelector).val(data.validValue).change(); // When the value in the field is changed expect(view.fieldValue()).toBe(data.validValue); expectMessageContains(view, view.indicators['inProgress']); @@ -139,9 +154,14 @@ define(['backbone', 'jquery', 'underscore', 'js/common_helpers/ajax_helpers', 'j AjaxHelpers.respondWithNoContent(requests); // When server returns success. - expectMessageContains(view, view.indicators['success']); + if (data.editable === 'toggle') { + expect(view.el).toHaveClass('mode-display'); + view.$el.click(); + } else { + expectMessageContains(view, view.indicators['success']); + } - view.$(data.valueElementSelector).val(data.invalidValue1).change(); + view.$(data.valueInputSelector).val(data.invalidValue1).change(); request_data[data.valueAttribute] = data.invalidValue1; AjaxHelpers.expectJsonRequest( requests, 'PATCH', url, request_data @@ -150,8 +170,9 @@ define(['backbone', 'jquery', 'underscore', 'js/common_helpers/ajax_helpers', 'j // When server returns a 500 error expectMessageContains(view, view.indicators['error']); expectMessageContains(view, view.messages['error']); + expect(view.el).toHaveClass('mode-edit'); - view.$(data.valueElementSelector).val(data.invalidValue2).change(); + view.$(data.valueInputSelector).val(data.invalidValue2).change(); request_data[data.valueAttribute] = data.invalidValue2; AjaxHelpers.expectJsonRequest( requests, 'PATCH', url, request_data @@ -160,12 +181,29 @@ define(['backbone', 'jquery', 'underscore', 'js/common_helpers/ajax_helpers', 'j // When server returns a validation error expectMessageContains(view, view.indicators['validationError']); expectMessageContains(view, data.validationError); + expect(view.el).toHaveClass('mode-edit'); + + view.$(data.valueInputSelector).val('').change(); + // When the value in the field is changed + expect(view.fieldValue()).toBe(''); + request_data[data.valueAttribute] = ''; + AjaxHelpers.expectJsonRequest( + requests, 'PATCH', url, request_data + ); + AjaxHelpers.respondWithNoContent(requests); + // When server returns success. + if (data.editable === 'toggle') { + expect(view.el).toHaveClass('mode-placeholder'); + } else { + expect(view.el).toHaveClass('mode-edit'); + } }; var verifyTextField = function (view, data, requests) { var selector = '.u-field-value > input'; verifyEditableField(view, _.extend({ - valueElementSelector: selector, + valueSelector: '.u-field-value', + valueInputSelector: '.u-field-value > input' }, data ), requests); } @@ -173,7 +211,8 @@ define(['backbone', 'jquery', 'underscore', 'js/common_helpers/ajax_helpers', 'j var verifyDropDownField = function (view, data, requests) { var selector = '.u-field-value > select'; verifyEditableField(view, _.extend({ - valueElementSelector: selector, + valueSelector: '.u-field-value', + valueInputSelector: '.u-field-value > select' }, data ), requests); } @@ -183,6 +222,7 @@ define(['backbone', 'jquery', 'underscore', 'js/common_helpers/ajax_helpers', 'j UserAccountModel: UserAccountModel, createFieldData: createFieldData, createErrorMessage: createErrorMessage, + expectTitleToBe: expectTitleToBe, expectTitleAndMessageToBe: expectTitleAndMessageToBe, expectMessageContains: expectMessageContains, expectAjaxRequestWithData: expectAjaxRequestWithData, diff --git a/lms/static/js/spec/views/fields_spec.js b/lms/static/js/spec/views/fields_spec.js index eee02c9886a0..58e415644494 100644 --- a/lms/static/js/spec/views/fields_spec.js +++ b/lms/static/js/spec/views/fields_spec.js @@ -7,7 +7,8 @@ define(['backbone', 'jquery', 'underscore', 'js/common_helpers/ajax_helpers', 'j var USERNAME = 'Legolas', FULLNAME = 'Legolas Thranduil', - EMAIL = 'legolas@woodland.middlearth'; + EMAIL = 'legolas@woodland.middlearth', + BIO = "My Name is Theon Greyjoy. I'm member of House Greyjoy"; describe("edx.FieldViews", function () { @@ -19,6 +20,8 @@ define(['backbone', 'jquery', 'underscore', 'js/common_helpers/ajax_helpers', 'j FieldViews.TextFieldView, FieldViews.DropdownFieldView, FieldViews.LinkFieldView, + FieldViews.TextareaFieldView + ]; beforeEach(function () { @@ -26,6 +29,7 @@ define(['backbone', 'jquery', 'underscore', 'js/common_helpers/ajax_helpers', 'j TemplateHelpers.installTemplate('templates/fields/field_dropdown'); TemplateHelpers.installTemplate('templates/fields/field_link'); TemplateHelpers.installTemplate('templates/fields/field_text'); + TemplateHelpers.installTemplate('templates/fields/field_textarea'); timerCallback = jasmine.createSpy('timerCallback'); jasmine.Clock.useMock(); @@ -55,7 +59,7 @@ define(['backbone', 'jquery', 'underscore', 'js/common_helpers/ajax_helpers', 'j title: 'Username', valueAttribute: 'username', helpMessage: 'The username that you use to sign in to edX.' - }) + }); var view = new fieldViewClass(fieldData).render(); FieldViewsSpecHelpers.verifySuccessMessageReset(view, fieldData, timerCallback); @@ -66,7 +70,7 @@ define(['backbone', 'jquery', 'underscore', 'js/common_helpers/ajax_helpers', 'j requests = AjaxHelpers.requests(this); - var fieldViewClass = FieldViews.FieldView; + var fieldViewClass = FieldViews.EditableFieldView; var fieldData = FieldViewsSpecHelpers.createFieldData(fieldViewClass, { title: 'Preferred Language', valueAttribute: 'language', @@ -101,7 +105,7 @@ define(['backbone', 'jquery', 'underscore', 'js/common_helpers/ajax_helpers', 'j expect(view.$('.u-field-value input').val().trim()).toBe('bookworm'); }); - it("correctly renders, updates and persists changes to TextFieldView", function() { + it("correctly renders, updates and persists changes to TextFieldView when editable == always", function() { requests = AjaxHelpers.requests(this); @@ -123,7 +127,28 @@ define(['backbone', 'jquery', 'underscore', 'js/common_helpers/ajax_helpers', 'j }, requests); }); - it("correctly renders, updates and persists changes to DropdownFieldView", function() { + it("correctly renders and updates DropdownFieldView when editable == never", function() { + + requests = AjaxHelpers.requests(this); + + var fieldData = FieldViewsSpecHelpers.createFieldData(FieldViews.DropdownFieldView, { + title: 'Full Name', + valueAttribute: 'name', + helpMessage: 'edX full name', + editable: 'never' + + }); + var view = new FieldViews.DropdownFieldView(fieldData).render(); + FieldViewsSpecHelpers.expectTitleAndMessageToBe(view, fieldData.title, fieldData.helpMessage); + expect(view.el).toHaveClass('mode-hidden'); + + view.model.set({'name': fieldData.options[1][0]}); + expect(view.el).toHaveClass('mode-display'); + view.$el.click(); + expect(view.el).toHaveClass('mode-display'); + }); + + it("correctly renders, updates and persists changes to DropdownFieldView when editable == always", function() { requests = AjaxHelpers.requests(this); @@ -145,6 +170,93 @@ define(['backbone', 'jquery', 'underscore', 'js/common_helpers/ajax_helpers', 'j }, requests); }); + it("correctly renders, updates and persists changes to DropdownFieldView when editable == toggle", function() { + + requests = AjaxHelpers.requests(this); + + var fieldData = FieldViewsSpecHelpers.createFieldData(FieldViews.DropdownFieldView, { + title: 'Full Name', + valueAttribute: 'name', + helpMessage: 'edX full name', + editable: 'toggle' + }); + var view = new FieldViews.DropdownFieldView(fieldData).render(); + + FieldViewsSpecHelpers.verifyDropDownField(view, { + title: fieldData.title, + valueAttribute: fieldData.valueAttribute, + helpMessage: fieldData.helpMessage, + editable: 'toggle', + validValue: FieldViewsSpecHelpers.SELECT_OPTIONS[0][0], + invalidValue1: FieldViewsSpecHelpers.SELECT_OPTIONS[1][0], + invalidValue2: FieldViewsSpecHelpers.SELECT_OPTIONS[2][0], + validationError: "Nope, this will not do!" + }, requests); + }); + + it("correctly renders and updates TextAreaFieldView when editable == never", function() { + var fieldData = FieldViewsSpecHelpers.createFieldData(FieldViews.TextareaFieldView, { + title: 'About me', + valueAttribute: 'bio', + helpMessage: 'Wicked is good', + placeholderValue: "Tell other edX learners a little about yourself: where you live, what your interests are, why you’re taking courses on edX, or what you hope to learn.", + editable: 'never' + }); + + // set bio to empty to see the placeholder. + fieldData.model.set({bio: ''}); + var view = new FieldViews.TextareaFieldView(fieldData).render(); + FieldViewsSpecHelpers.expectTitleAndMessageToBe(view, fieldData.title, fieldData.helpMessage); + expect(view.el).toHaveClass('mode-hidden'); + expect(view.$('.u-field-value').text()).toBe(fieldData.placeholderValue); + + var bio = 'Too much to tell!' + view.model.set({'bio': bio}); + expect(view.el).toHaveClass('mode-display'); + expect(view.$('.u-field-value').text()).toBe(bio); + view.$el.click(); + expect(view.el).toHaveClass('mode-display'); + }); + + it("correctly renders, updates and persists changes to TextAreaFieldView when editable == toggle", function() { + + requests = AjaxHelpers.requests(this); + + var valueInputSelector = '.u-field-value > textarea' + var fieldData = FieldViewsSpecHelpers.createFieldData(FieldViews.TextareaFieldView, { + title: 'About me', + valueAttribute: 'bio', + helpMessage: 'Wicked is good', + placeholderValue: "Tell other edX learners a little about yourself: where you live, what your interests are, why you’re taking courses on edX, or what you hope to learn.", + editable: 'toggle' + + }); + fieldData.model.set({'bio': ''}); + + var view = new FieldViews.TextareaFieldView(fieldData).render(); + + FieldViewsSpecHelpers.expectTitleToBe(view, fieldData.title); + FieldViewsSpecHelpers.expectMessageContains(view, view.indicators['canEdit']); + expect(view.el).toHaveClass('mode-placeholder'); + expect(view.$('.u-field-value').text()).toBe(fieldData.placeholderValue); + + view.$('.wrapper-u-field').click(); + expect(view.el).toHaveClass('mode-edit'); + view.$(valueInputSelector).val(BIO).focusout(); + expect(view.fieldValue()).toBe(BIO); + AjaxHelpers.expectJsonRequest( + requests, 'PATCH', view.model.url, {'bio': BIO} + ); + AjaxHelpers.respondWithNoContent(requests); + expect(view.el).toHaveClass('mode-display'); + + view.$('.wrapper-u-field').click(); + view.$(valueInputSelector).val('').focusout(); + AjaxHelpers.respondWithNoContent(requests); + expect(view.el).toHaveClass('mode-placeholder'); + expect(view.$('.u-field-value').text()).toBe(fieldData.placeholderValue); + }); + it("correctly renders LinkFieldView", function() { var fieldData = FieldViewsSpecHelpers.createFieldData(FieldViews.LinkFieldView, { title: 'Title', @@ -157,5 +269,4 @@ define(['backbone', 'jquery', 'underscore', 'js/common_helpers/ajax_helpers', 'j expect(view.$('.u-field-value > a').text().trim()).toBe(fieldData.linkTitle); }); }); - }); diff --git a/lms/static/js/student_account/models/user_account_model.js b/lms/static/js/student_account/models/user_account_model.js index 1eeb5dd8d75a..e7afa5b19426 100644 --- a/lms/static/js/student_account/models/user_account_model.js +++ b/lms/static/js/student_account/models/user_account_model.js @@ -19,7 +19,24 @@ level_of_education: null, mailing_address: "", year_of_birth: null, - language_proficiencies: [] + bio: null, + language_proficiencies: [], + requires_parental_consent: true, + default_public_account_fields: [] + }, + + parse : function(response, xhr) { + if (_.isNull(response)) { + return {}; + } + + // Currently when a non-staff user A access user B's profile, the only way to tell whether user B's + // profile is public is to check if the api has returned fields other than the default public fields + // specified in settings.ACCOUNT_VISIBILITY_CONFIGURATION. + var profileIsPublic = _.size(_.difference(_.keys(response), this.get('default_public_account_fields'))) > 0; + this.set({'profile_is_public': profileIsPublic}, { silent: true }); + + return response; } }); diff --git a/lms/static/js/student_profile/views/learner_profile_factory.js b/lms/static/js/student_profile/views/learner_profile_factory.js new file mode 100644 index 000000000000..f524c6d4ffdc --- /dev/null +++ b/lms/static/js/student_profile/views/learner_profile_factory.js @@ -0,0 +1,131 @@ +;(function (define, undefined) { + 'use strict'; + define([ + 'gettext', 'jquery', 'underscore', 'backbone', + 'js/student_account/models/user_account_model', + 'js/student_account/models/user_preferences_model', + 'js/views/fields', + 'js/student_profile/views/learner_profile_fields', + 'js/student_profile/views/learner_profile_view', + 'js/student_account/views/account_settings_fields' + + + ], function (gettext, $, _, Backbone, AccountSettingsModel, AccountPreferencesModel, FieldsView, + LearnerProfileFieldsView, LearnerProfileView, AccountSettingsFieldViews) { + + return function (options) { + + var learnerProfileElement = $('.wrapper-profile'); + + var accountPreferencesModel = new AccountPreferencesModel(); + accountPreferencesModel.url = options['preferences_api_url']; + + var accountSettingsModel = new AccountSettingsModel({ + 'default_public_account_fields': options['default_public_account_fields'] + }); + accountSettingsModel.url = options['accounts_api_url']; + + var editable = options['own_profile'] ? 'toggle' : 'never'; + + var accountPrivacyFieldView = new LearnerProfileFieldsView.AccountPrivacyFieldView({ + model: accountPreferencesModel, + required: true, + editable: 'always', + showMessages: false, + title: gettext('edX learners can see my:'), + valueAttribute: "account_privacy", + options: [ + ['private', gettext('Limited Profile')], + ['all_users', gettext('Full Profile')] + ], + helpMessage: '', + accountSettingsPageUrl: options['account_settings_page_url'] + }); + + var usernameFieldView = new FieldsView.ReadonlyFieldView({ + model: accountSettingsModel, + valueAttribute: "username", + helpMessage: "" + }); + + var sectionOneFieldViews = [ + usernameFieldView, + new FieldsView.DropdownFieldView({ + model: accountSettingsModel, + required: true, + editable: editable, + showMessages: false, + iconName: 'fa-map-marker', + placeholderValue: gettext('Add country'), + valueAttribute: "country", + options: options['country_options'], + helpMessage: '' + }), + new AccountSettingsFieldViews.LanguageProficienciesFieldView({ + model: accountSettingsModel, + required: false, + editable: editable, + showMessages: false, + iconName: 'fa-comment', + placeholderValue: gettext('Add language'), + valueAttribute: "language_proficiencies", + options: options['language_options'], + helpMessage: '' + }) + ]; + + var sectionTwoFieldViews = [ + new FieldsView.TextareaFieldView({ + model: accountSettingsModel, + editable: editable, + showMessages: false, + title: gettext('About me'), + placeholderValue: gettext("Tell other edX learners a little about yourself: where you live, what your interests are, why you're taking courses on edX, or what you hope to learn."), + valueAttribute: "bio", + helpMessage: '' + }) + ]; + + var learnerProfileView = new LearnerProfileView({ + el: learnerProfileElement, + own_profile: options['own_profile'], + has_preferences_access: options['has_preferences_access'], + accountSettingsModel: accountSettingsModel, + preferencesModel: accountPreferencesModel, + accountPrivacyFieldView: accountPrivacyFieldView, + usernameFieldView: usernameFieldView, + sectionOneFieldViews: sectionOneFieldViews, + sectionTwoFieldViews: sectionTwoFieldViews + }); + + var showLoadingError = function () { + learnerProfileView.showLoadingError(); + }; + + var renderLearnerProfileView = function() { + learnerProfileView.render(); + }; + + accountSettingsModel.fetch({ + success: function () { + if (options['has_preferences_access']) { + accountPreferencesModel.fetch({ + success: renderLearnerProfileView, + error: showLoadingError + }); + } + else { + renderLearnerProfileView(); + } + }, + error: showLoadingError + }); + + return { + accountSettingsModel: accountSettingsModel, + accountPreferencesModel: accountPreferencesModel, + learnerProfileView: learnerProfileView + }; + }; + }) +}).call(this, define || RequireJS.define); diff --git a/lms/static/js/student_profile/views/learner_profile_fields.js b/lms/static/js/student_profile/views/learner_profile_fields.js new file mode 100644 index 000000000000..8bdc66fd0b96 --- /dev/null +++ b/lms/static/js/student_profile/views/learner_profile_fields.js @@ -0,0 +1,38 @@ +;(function (define, undefined) { + 'use strict'; + define([ + 'gettext', 'jquery', 'underscore', 'backbone', 'js/views/fields', 'backbone-super' + ], function (gettext, $, _, Backbone, FieldViews) { + + var LearnerProfileFieldViews = {}; + + LearnerProfileFieldViews.AccountPrivacyFieldView = FieldViews.DropdownFieldView.extend({ + + render: function () { + this._super(); + this.message(); + return this; + }, + + message: function () { + if (this.profileIsPrivate) { + this._super(interpolate_text( + gettext("You must specify your birth year before you can share your full profile. To specify your birth year, go to the {account_settings_page_link}"), + {'account_settings_page_link': '' + gettext('Account Settings page.') + ''} + )); + } else if (this.requiresParentalConsent) { + this._super(interpolate_text( + gettext('You must be over 13 to share a full profile. If you are over 13, make sure that you have specified a birth year on the {account_settings_page_link}'), + {'account_settings_page_link': '' + gettext('Account Settings page.') + ''} + )); + } + else { + this._super(''); + } + return this._super(); + } + }); + + return LearnerProfileFieldViews; + }) +}).call(this, define || RequireJS.define); diff --git a/lms/static/js/student_profile/views/learner_profile_view.js b/lms/static/js/student_profile/views/learner_profile_view.js new file mode 100644 index 000000000000..0dea1e10661e --- /dev/null +++ b/lms/static/js/student_profile/views/learner_profile_view.js @@ -0,0 +1,71 @@ +;(function (define, undefined) { + 'use strict'; + define([ + 'gettext', 'jquery', 'underscore', 'backbone' + ], function (gettext, $, _, Backbone) { + + var LearnerProfileView = Backbone.View.extend({ + + initialize: function (options) { + this.template = _.template($('#learner_profile-tpl').text()); + _.bindAll(this, 'showFullProfile', 'render', 'renderFields', 'showLoadingError'); + this.listenTo(this.options.preferencesModel, "change:" + 'account_privacy', this.render); + }, + + showFullProfile: function () { + if (this.options.own_profile) { + return this.options.preferencesModel.get('account_privacy') === 'all_users'; + } else { + return this.options.accountSettingsModel.get('profile_is_public'); + } + }, + + render: function () { + this.$el.html(this.template({ + username: this.options.accountSettingsModel.get('username'), + profilePhoto: 'http://www.teachthought.com/wp-content/uploads/2012/07/edX-120x120.jpg', + ownProfile: this.options.own_profile, + showFullProfile: this.showFullProfile() + })); + this.renderFields(); + return this; + }, + + renderFields: function() { + var view = this; + + if (this.options.own_profile) { + var fieldView = this.options.accountPrivacyFieldView; + fieldView.profileIsPrivate = (!this.options.accountSettingsModel.get('year_of_birth')); + fieldView.requiresParentalConsent = (this.options.accountSettingsModel.get('requires_parental_consent')); + fieldView.undelegateEvents(); + this.$('.wrapper-profile-field-account-privacy').append(fieldView.render().el); + fieldView.delegateEvents(); + } + + this.$('.profile-section-one-fields').append(this.options.usernameFieldView.render().el); + + if (this.showFullProfile()) { + _.each(this.options.sectionOneFieldViews, function (fieldView, index) { + fieldView.undelegateEvents(); + view.$('.profile-section-one-fields').append(fieldView.render().el); + fieldView.delegateEvents(); + }); + + _.each(this.options.sectionTwoFieldViews, function (fieldView, index) { + fieldView.undelegateEvents(); + view.$('.profile-section-two-fields').append(fieldView.render().el); + fieldView.delegateEvents(); + }); + } + }, + + showLoadingError: function () { + this.$('.ui-loading-indicator').addClass('is-hidden'); + this.$('.ui-loading-error').removeClass('is-hidden'); + } + }); + + return LearnerProfileView; + }) +}).call(this, define || RequireJS.define); diff --git a/lms/static/js/views/fields.js b/lms/static/js/views/fields.js index d643cc1eddae..2b154de08a3d 100644 --- a/lms/static/js/views/fields.js +++ b/lms/static/js/views/fields.js @@ -10,7 +10,7 @@ var FieldViews = {}; FieldViews.FieldView = Backbone.View.extend({ - + fieldType: 'generic', className: function () { @@ -20,13 +20,16 @@ tagName: 'div', indicators: { - 'error': '', - 'validationError': '', - 'inProgress': '', - 'success': '' + 'canEdit': '', + 'error': '', + 'validationError': '', + 'inProgress': '', + 'success': '', + 'plus': '' }, messages: { + 'canEdit': '', 'error': gettext('An error occurred. Please try again.'), 'validationError': '', 'inProgress': gettext('Saving'), @@ -40,39 +43,26 @@ this.helpMessage = this.options.helpMessage || ''; this.showMessages = _.isUndefined(this.options.showMessages) ? true : this.options.showMessages; - _.bindAll(this, 'modelValue', 'saveAttributes', 'saveSucceeded', 'getMessage', - 'message', 'showHelpMessage', 'showInProgressMessage', 'showSuccessMessage', 'showErrorMessage'); + _.bindAll(this, 'modelValue', 'modelValueIsSet', 'message', 'getMessage', 'title', + 'showHelpMessage', 'showInProgressMessage', 'showSuccessMessage', 'showErrorMessage'); }, modelValue: function () { return this.model.get(this.options.valueAttribute); }, - saveAttributes: function (attributes, options) { - var view = this; - var defaultOptions = { - contentType: 'application/merge-patch+json', - patch: true, - wait: true, - success: function (model, response, options) { - view.saveSucceeded() - }, - error: function (model, xhr, options) { - view.showErrorMessage(xhr) - } - }; - this.showInProgressMessage(); - this.model.save(attributes, _.extend(defaultOptions, options)); - }, - - saveSucceeded: function () { - this.showSuccessMessage(); + modelValueIsSet: function() { + return (this.modelValue() == true); }, message: function (message) { return this.$('.u-field-message').html(message); }, + title: function (text) { + return this.$('.u-field-title').html(text); + }, + getMessage: function(message_status) { if ((message_status + 'Message') in this) { return this[message_status + 'Message'].call(this); @@ -82,6 +72,14 @@ return this.indicators[message_status]; }, + showCanEditMessage: function(show) { + if (!_.isUndefined(show) && show) { + this.message(this.getMessage('canEdit')); + } else { + this.message(''); + } + }, + showHelpMessage: function () { this.message(this.helpMessage); }, @@ -126,6 +124,84 @@ } }); + FieldViews.EditableFieldView = FieldViews.FieldView.extend({ + + initialize: function (options) { + _.bindAll(this, 'saveAttributes', 'saveSucceeded', 'showDisplayMode', 'showEditMode', 'startEditing', 'finishEditing'); + this._super(options); + + this.editable = _.isUndefined(this.options.editable) ? 'always': this.options.editable; + this.$el.addClass('editable-' + this.editable); + + if (this.editable === 'always') { + this.showEditMode(false); + } else { + this.showDisplayMode(false); + } + }, + + saveAttributes: function (attributes, options) { + var view = this; + var defaultOptions = { + contentType: 'application/merge-patch+json', + patch: true, + wait: true, + success: function () { + view.saveSucceeded(); + }, + error: function (model, xhr) { + view.showErrorMessage(xhr); + } + }; + this.showInProgressMessage(); + this.model.save(attributes, _.extend(defaultOptions, options)); + }, + + saveSucceeded: function () { + this.showSuccessMessage(); + }, + + showDisplayMode: function(render) { + this.mode = 'display'; + if (render) { this.render(); } + + this.$el.removeClass('mode-edit'); + + this.$el.toggleClass('mode-hidden', (this.editable === 'never' && !this.modelValueIsSet())); + this.$el.toggleClass('mode-placeholder', (this.editable === 'toggle' && !this.modelValueIsSet())); + this.$el.toggleClass('mode-display', (this.modelValueIsSet())); + }, + + showEditMode: function(render) { + this.mode = 'edit'; + if (render) { this.render(); } + + this.$el.removeClass('mode-hidden'); + this.$el.removeClass('mode-placeholder'); + this.$el.removeClass('mode-display'); + + this.$el.addClass('mode-edit'); + }, + + startEditing: function (event) { + if (this.editable === 'toggle' && this.mode !== 'edit') { + this.showEditMode(true); + } + }, + + finishEditing: function(event) { + if (this.fieldValue() !== this.modelValue()) { + this.saveValue(); + } else { + if (this.editable === 'always') { + this.showEditMode(true); + } else { + this.showDisplayMode(true); + } + } + } + }); + FieldViews.ReadonlyFieldView = FieldViews.FieldView.extend({ fieldType: 'readonly', @@ -157,7 +233,7 @@ } }); - FieldViews.TextFieldView = FieldViews.FieldView.extend({ + FieldViews.TextFieldView = FieldViews.EditableFieldView.extend({ fieldType: 'text', @@ -199,47 +275,188 @@ } }); - FieldViews.DropdownFieldView = FieldViews.FieldView.extend({ + FieldViews.DropdownFieldView = FieldViews.EditableFieldView.extend({ fieldType: 'dropdown', templateSelector: '#field_dropdown-tpl', events: { - 'change select': 'saveValue' + 'click': 'startEditing', + 'change select': 'finishEditing', + 'focusout select': 'finishEditing' }, initialize: function (options) { + _.bindAll(this, 'render', 'optionForValue', 'fieldValue', 'displayValue', 'updateValueInField', 'saveValue'); this._super(options); - _.bindAll(this, 'render', 'fieldValue', 'updateValueInField', 'saveValue'); + this.listenTo(this.model, "change:" + this.options.valueAttribute, this.updateValueInField); }, render: function () { this.$el.html(this.template({ id: this.options.valueAttribute, + mode: this.mode, title: this.options.title, + iconName: this.options.iconName, required: this.options.required, selectOptions: this.options.options, message: this.helpMessage })); + this.updateValueInField(); + + if (this.editable === 'toggle') { + this.showCanEditMessage(this.mode === 'display'); + } return this; }, + modelValueIsSet: function() { + var value = this.modelValue(); + if (_.isUndefined(value) || _.isNull(value) || value == '') { + return false; + } else { + return !(_.isUndefined(this.optionForValue(value))) + } + }, + + optionForValue: function(value) { + return _.find(this.options.options, function(option) { return option[0] == value; }) + }, + fieldValue: function () { return this.$('.u-field-value select').val(); }, + displayValue: function (value) { + if (value) { + var option = this.optionForValue(value); + return (option ? option[1] : ''); + } else { + return ''; + } + }, + updateValueInField: function () { - var value = (_.isUndefined(this.modelValue()) || _.isNull(this.modelValue())) ? '' : this.modelValue(); - this.$('.u-field-value select').val(Mustache.escapeHtml(value)); + if (this.mode === 'display') { + var value = this.displayValue(this.modelValue() || ''); + if (this.modelValueIsSet() === false) { + value = this.options.placeholderValue || ''; + } + this.$('.u-field-value').html(Mustache.escapeHtml(value)); + this.showDisplayMode(false); + } else { + this.$('.u-field-value select').val(this.modelValue() || ''); + } + }, + + saveValue: function () { + var attributes = {}; + attributes[this.options.valueAttribute] = this.fieldValue(); + this.saveAttributes(attributes); + }, + + showEditMode: function(render) { + this._super(render); + if (this.editable === 'toggle') { + this.$('.u-field-value select').focus(); + } + }, + + saveSucceeded: function() { + this._super(); + if (this.editable === 'toggle') { + this.showDisplayMode(true); + } + } + }); + + FieldViews.TextareaFieldView = FieldViews.EditableFieldView.extend({ + + fieldType: 'textarea', + + templateSelector: '#field_textarea-tpl', + + events: { + 'click .wrapper-u-field': 'startEditing', + 'click .u-field-placeholder': 'startEditing', + 'focusout textarea': 'finishEditing', + 'change textarea': 'adjustTextareaHeight', + 'keyup textarea': 'adjustTextareaHeight', + 'keydown textarea': 'adjustTextareaHeight', + 'paste textarea': 'adjustTextareaHeight', + 'cut textarea': 'adjustTextareaHeight' + }, + + initialize: function (options) { + _.bindAll(this, 'render', 'adjustTextareaHeight', 'fieldValue', 'saveValue', 'updateView'); + this._super(options); + this.listenTo(this.model, "change:" + this.options.valueAttribute, this.updateView); + }, + + render: function () { + var value = this.modelValue(); + if (this.mode === 'display') { + value = value || this.options.placeholderValue; + } + this.$el.html(this.template({ + id: this.options.valueAttribute, + mode: this.mode, + value: value, + message: this.helpMessage + })); + + this.title((this.modelValue() || this.mode === 'edit') ? this.options.title : this.indicators['plus'] + this.options.title); + + if (this.editable === 'toggle') { + this.showCanEditMessage(this.mode === 'display'); + } + return this; + }, + + adjustTextareaHeight: function(event) { + var textarea = this.$('textarea'); + textarea.css('height', 'auto').css('height', textarea.prop('scrollHeight') + 10); + }, + + modelValue: function() { + var value = this._super(); + return value ? $.trim(value) : ''; + }, + + fieldValue: function () { + return this.$('.u-field-value textarea').val(); }, saveValue: function () { var attributes = {}; attributes[this.options.valueAttribute] = this.fieldValue(); this.saveAttributes(attributes); + }, + + updateView: function () { + if (this.mode !== 'edit') { + this.showDisplayMode(true); + } + }, + + modelValueIsSet: function() { + return !(this.modelValue() === ''); + }, + + showEditMode: function(render) { + this._super(render); + this.adjustTextareaHeight(); + this.$('.u-field-value textarea').focus(); + }, + + saveSucceeded: function() { + this._super(); + if (this.editable === 'toggle') { + this.showDisplayMode(true); + } } }); diff --git a/lms/static/sass/application-extend2.scss.mako b/lms/static/sass/application-extend2.scss.mako index d59ff93f4491..989232e8aaff 100644 --- a/lms/static/sass/application-extend2.scss.mako +++ b/lms/static/sass/application-extend2.scss.mako @@ -46,6 +46,7 @@ // base - specific views @import "views/account-settings"; +@import "views/learner-profile"; @import 'views/login-register'; @import 'views/verification'; @import 'views/decoupled-verification'; diff --git a/lms/static/sass/shared/_fields.scss b/lms/static/sass/shared/_fields.scss index 0f14daf16d73..f2716343e5a6 100644 --- a/lms/static/sass/shared/_fields.scss +++ b/lms/static/sass/shared/_fields.scss @@ -5,6 +5,49 @@ .u-field { padding: $baseline 0; border-bottom: 1px solid $gray-l5; + border: 1px dashed transparent; + + &.mode-placeholder { + border: 2px dashed transparent; + border-radius: 3px; + + span { + color: $gray-l1; + } + + &:hover { + border: 2px dashed $link-color; + + span { + color: $link-color; + } + } + } + + &.editable-toggle.mode-display:hover { + background-color: $m-blue-l4; + border-radius: 3px; + + .message-can-edit { + display: inline-block; + color: $link-color; + } + + } + + &.mode-hidden { + display: none; + } + + i { + color: $gray-l2; + vertical-align:text-bottom; + margin-right: 5px; + } + + .message-can-edit { + display: none; + } .message-error { color: $alert-color; @@ -33,10 +76,15 @@ } } +.u-field-icon { + width: $baseline; + color: $gray-l2; +} + .u-field-title { width: flex-grid(3, 12); display: inline-block; - color: $dark-gray1; + color: $gray; vertical-align: top; margin-bottom: 0; @@ -56,12 +104,12 @@ } .u-field-message { - @extend small; + @extend %t-copy-sub1; @include padding-left($baseline/2); width: flex-grid(6, 12); display: inline-block; vertical-align: top; - color: $dark-gray1; + color: $gray-l1; i { @include margin-right($baseline/4); diff --git a/lms/static/sass/views/_learner-profile.scss b/lms/static/sass/views/_learner-profile.scss new file mode 100644 index 000000000000..b02a663b4524 --- /dev/null +++ b/lms/static/sass/views/_learner-profile.scss @@ -0,0 +1,199 @@ +// lms - application - learner profile +// ==================== + +// Table of Contents +// * +Container - Learner Profile +// * +Main - Header +// * +Settings Section + +.view-profile { + $profile-photo-dimension: 120px; + + .content-wrapper { + background-color: $white; + } + + .ui-loading-indicator { + @extend .ui-loading-base; + padding-bottom: $baseline; + + // center horizontally + @include margin-left(auto); + @include margin-right(auto); + width: ($baseline*5); + } + + .wrapper-profile { + min-height: 200px; + + .ui-loading-indicator { + margin-top: 100px; + } + } + + .profile-self { + .wrapper-profile-field-account-privacy { + @include clearfix(); + @include box-sizing(border-box); + margin: 0 auto 0; + padding: ($baseline*0.75) 0; + width: 100%; + background-color: $gray-l3; + + .u-field-account_privacy { + @extend .container; + border: none; + box-shadow: none; + padding: 0 ($baseline*1.5); + } + + .u-field-title { + width: auto; + color: $base-font-color; + font-weight: $font-bold; + cursor: text; + } + + .u-field-value { + width: auto; + @include margin-left($baseline/2); + } + + .u-field-message { + @include float(left); + width: 100%; + padding: 0; + color: $base-font-color; + } + } + } + + .wrapper-profile-sections { + @extend .container; + padding: 0 ($baseline*1.5); + } + + .wrapper-profile-section-one { + width: 100%; + display: inline-block; + margin-top: ($baseline*1.5); + + .profile-photo { + @include float(left); + height: $profile-photo-dimension; + width: $profile-photo-dimension; + display: inline-block; + vertical-align: top; + } + } + + .profile-section-one-fields { + float: left; + width: flex-grid(4, 12); + @include margin-left($baseline*1.5); + + .u-field { + margin-bottom: ($baseline/4); + padding-top: 0; + padding-bottom: 0; + @include padding-left(3px); + } + + .u-field-username { + margin-bottom: ($baseline/2); + + input[type="text"] { + font-weight: 600; + } + + .u-field-value { + width: 350px; + @extend %t-title4; + } + } + + .u-field-title { + width: 0; + } + + .u-field-value { + width: 200px; + } + + select { + width: 100% + } + + .u-field-message { + @include float(right); + width: 20px; + margin-top: 2px; + } + } + + .wrapper-profile-section-two { + width: flex-grid(8, 12); + margin-top: ($baseline*1.5); + } + + .profile-section-two-fields { + + .u-field-textarea { + margin-bottom: ($baseline/2); + padding: ($baseline/4) ($baseline/2) ($baseline/2); + } + + .u-field-title { + font-size: 1.1em; + @extend %t-weight4; + margin-bottom: ($baseline/4); + } + + .u-field-value { + width: 100%; + white-space: pre-line; + line-height: 1.5em; + + textarea { + width: 100%; + background-color: transparent; + } + } + + .u-field-message { + @include float(right); + width: auto; + padding-top: ($baseline/4); + } + + .u-field.mode-placeholder { + padding: $baseline; + border: 2px dashed $gray-l3; + i { + font-size: 12px; + padding-right: 5px; + vertical-align: middle; + color: $gray; + } + .u-field-title { + width: 100%; + text-align: center; + } + + .u-field-value { + text-align: center; + line-height: 1.5em; + @extend %t-copy-sub1; + color: $gray; + } + } + + .u-field.mode-placeholder:hover { + border: 2px dashed $link-color; + .u-field-title, + i { + color: $link-color; + } + } + } +} diff --git a/lms/templates/fields/field_dropdown.underscore b/lms/templates/fields/field_dropdown.underscore index 365e76d52c5c..25505cdd227b 100644 --- a/lms/templates/fields/field_dropdown.underscore +++ b/lms/templates/fields/field_dropdown.underscore @@ -1,16 +1,26 @@ - +<% if (title) { %> + +<% } %> + +<% if (iconName) { %> + +<% } %> + - + <% if (mode === 'edit') { %> + + <% } %> + diff --git a/lms/templates/fields/field_textarea.underscore b/lms/templates/fields/field_textarea.underscore new file mode 100644 index 000000000000..0f2f482d98fd --- /dev/null +++ b/lms/templates/fields/field_textarea.underscore @@ -0,0 +1,14 @@ +Loading
An error occurred. Please reload the page.+diff --git a/lms/templates/navigation-edx.html b/lms/templates/navigation-edx.html index 837effb1670f..75815e31ce21 100644 --- a/lms/templates/navigation-edx.html +++ b/lms/templates/navigation-edx.html @@ -83,8 +83,9 @@+ + ++ +<% + if (mode === 'edit') { + %><% + } else { + %><%- value %><% + } + %>+${course.display_org_with_default | h}: ${cour diff --git a/lms/templates/navigation.html b/lms/templates/navigation.html index e2a53966ec8e..8528d6b72364 100644 --- a/lms/templates/navigation.html +++ b/lms/templates/navigation.html @@ -91,8 +91,9 @@
${course.display_org_with_default | h}: diff --git a/lms/templates/student_profile/learner_profile.html b/lms/templates/student_profile/learner_profile.html new file mode 100644 index 000000000000..17be327cadd9 --- /dev/null +++ b/lms/templates/student_profile/learner_profile.html @@ -0,0 +1,43 @@ +<%! import json %> +<%! from django.core.urlresolvers import reverse %> +<%! from django.utils.translation import ugettext as _ %> + +<%inherit file="/main.html" /> +<%namespace name='static' file='/static_content.html'/> + +<%block name="pagetitle">${_("Learner Profile")}%block> + +<%block name="bodyclass">view-profile%block> + +<%block name="header_extras"> + % for template_name in ["field_dropdown", "field_textarea", "field_readonly"]: + + % endfor + + % for template_name in ["learner_profile",]: + + % endfor +%block> + +
++<%block name="headextra"> + <%static:css group='style-course'/> + + + +%block> diff --git a/lms/templates/student_profile/learner_profile.underscore b/lms/templates/student_profile/learner_profile.underscore new file mode 100644 index 000000000000..835dac223428 --- /dev/null +++ b/lms/templates/student_profile/learner_profile.underscore @@ -0,0 +1,29 @@ +++${_("Loading")}
++ + +diff --git a/lms/urls.py b/lms/urls.py index 92fac5da9023..61b2b0efc33c 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -417,9 +417,12 @@ url(r'^courses/{}/lti_rest_endpoints/'.format(settings.COURSE_ID_PATTERN), 'courseware.views.get_course_lti_endpoints', name='lti_rest_endpoints'), - # Student account and profile + # Student account url(r'^account/', include('student_account.urls')), + # Student profile + url(r'^u/(?P++++++ ++
+++ + <%- gettext("An error occurred. Please reload the page.") %> +++++ <% if (!showFullProfile) { %> + <% if(ownProfile) { %> + + <% } else { %> + + <% } %> + <% } %> ++[\w.@+-]+)$', 'student_profile.views.learner_profile', name='learner_profile'), + # Student Notes url(r'^courses/{}/edxnotes'.format(settings.COURSE_ID_PATTERN), include('edxnotes.urls'), name="edxnotes_endpoints"), From 22ee09bba02e178d57ce0589d861df790b0e72e2 Mon Sep 17 00:00:00 2001 From: muzaffaryousaf Date: Thu, 26 Mar 2015 11:26:11 +0500 Subject: [PATCH 06/25] Link to learner profile page from username in discussions. TNL-1503 --- .../test/acceptance/pages/lms/discussion.py | 8 ++++++-- .../tests/discussion/test_discussion.py | 20 +++++++++++++++++++ .../django_comment_client/forum/views.py | 5 ++++- lms/templates/discussion/_user_profile.html | 2 +- 4 files changed, 31 insertions(+), 4 deletions(-) diff --git a/common/test/acceptance/pages/lms/discussion.py b/common/test/acceptance/pages/lms/discussion.py index affe1f83e26c..2b2f17d9a855 100644 --- a/common/test/acceptance/pages/lms/discussion.py +++ b/common/test/acceptance/pages/lms/discussion.py @@ -444,9 +444,9 @@ def is_browser_on_page(self): return ( self.q(css='section.discussion-user-threads[data-course-id="{}"]'.format(self.course_id)).present and - self.q(css='section.user-profile div.sidebar-username').present + self.q(css='section.user-profile a.leaner-profile-link').present and - self.q(css='section.user-profile div.sidebar-username').text[0] == self.username + self.q(css='section.user-profile a.leaner-profile-link').text[0] == self.username ) @wait_for_js @@ -526,6 +526,10 @@ def click_on_page(self, page_number): "Window is on top" ).fulfill() + def click_on_sidebar_username(self): + self.wait_for_page() + self.q(css='.leaner-profile-link').first.click() + class DiscussionTabHomePage(CoursePage, DiscussionPageMixin): diff --git a/common/test/acceptance/tests/discussion/test_discussion.py b/common/test/acceptance/tests/discussion/test_discussion.py index 9faa4077c49e..597df3ad3fb1 100644 --- a/common/test/acceptance/tests/discussion/test_discussion.py +++ b/common/test/acceptance/tests/discussion/test_discussion.py @@ -19,6 +19,8 @@ DiscussionTabHomePage, DiscussionSortPreferencePage, ) +from ...pages.lms.learner_profile import LearnerProfilePage + from ...fixtures.course import CourseFixture, XBlockFixtureDesc from ...fixtures.discussion import ( SingleThreadViewFixture, @@ -753,6 +755,24 @@ def test_pagination_window_reposition(self): page.wait_for_ajax() self.assertTrue(page.is_window_on_top()) + def test_redirects_to_learner_profile(self): + """ + Scenario: Verify that learner-profile link is present on forum discussions page and we can navigate to it. + + Given that I am on discussion forum user's profile page. + And I can see a username on left sidebar + When I click on my username. + Then I will be navigated to Learner Profile page. + And I can my username on Learner Profile page + """ + learner_profile_page = LearnerProfilePage(self.browser, self.PROFILED_USERNAME) + + page = self.check_pages(1) + page.click_on_sidebar_username() + + learner_profile_page.wait_for_page() + self.assertTrue(learner_profile_page.field_is_visible('username')) + @attr('shard_1') class DiscussionSearchAlertTest(UniqueCourseTest): diff --git a/lms/djangoapps/django_comment_client/forum/views.py b/lms/djangoapps/django_comment_client/forum/views.py index 57f61e1de0a7..1e66a97c9f41 100644 --- a/lms/djangoapps/django_comment_client/forum/views.py +++ b/lms/djangoapps/django_comment_client/forum/views.py @@ -9,6 +9,7 @@ from django.contrib.auth.decorators import login_required from django.core.context_processors import csrf +from django.core.urlresolvers import reverse from django.contrib.auth.models import User from django.http import Http404, HttpResponseBadRequest from django.views.decorators.http import require_GET @@ -411,16 +412,18 @@ def user_profile(request, course_key, user_id): 'annotated_content_info': _attr_safe_json(annotated_content_info), }) else: + django_user = User.objects.get(id=user_id) context = { 'course': course, 'user': request.user, - 'django_user': User.objects.get(id=user_id), + 'django_user': django_user, 'profiled_user': profiled_user.to_dict(), 'threads': _attr_safe_json(threads), 'user_info': _attr_safe_json(user_info), 'annotated_content_info': _attr_safe_json(annotated_content_info), 'page': query_params['page'], 'num_pages': query_params['num_pages'], + 'learner_profile_page_url': reverse('learner_profile', kwargs={'username': django_user.username}) } return render_to_response('discussion/user_profile.html', context) diff --git a/lms/templates/discussion/_user_profile.html b/lms/templates/discussion/_user_profile.html index b6e845c45aba..d4cb4668f45a 100644 --- a/lms/templates/discussion/_user_profile.html +++ b/lms/templates/discussion/_user_profile.html @@ -1,7 +1,7 @@ <%! from django.utils.translation import ugettext as _, ungettext %> <%def name="span(num)">${num}%def> - - - - -<%include file='modal/_modal-settings-language.html' /> - - -