From adf111e4d515abc808375f46b1987fde5ce3dd07 Mon Sep 17 00:00:00 2001 From: Usman Khalid <2200617@gmail.com> Date: Fri, 3 Apr 2015 15:09:07 +0500 Subject: [PATCH] Adding functionlity to upload/remove profile image. TNL-1538 --- lms/djangoapps/student_profile/views.py | 4 + lms/static/js/spec/student_account/helpers.js | 11 +- .../learner_profile_view_spec.js | 24 ++- .../models/user_account_model.js | 10 + .../views/learner_profile_factory.js | 24 ++- .../views/learner_profile_fields.js | 58 +++++ .../views/learner_profile_view.js | 6 +- lms/static/js/views/fields.js | 201 ++++++++++++++++++ lms/static/js/views/message_banner.js | 32 +++ lms/static/sass/views/_learner-profile.scss | 79 ++++++- lms/templates/fields/field_image.underscore | 15 ++ lms/templates/message_banner.underscore | 9 + .../student_profile/learner_profile.html | 15 +- .../learner_profile.underscore | 3 +- 14 files changed, 468 insertions(+), 23 deletions(-) create mode 100644 lms/static/js/views/message_banner.js create mode 100644 lms/templates/fields/field_image.underscore create mode 100644 lms/templates/message_banner.underscore diff --git a/lms/djangoapps/student_profile/views.py b/lms/djangoapps/student_profile/views.py index e0fa845dca..9ea1138f14 100644 --- a/lms/djangoapps/student_profile/views.py +++ b/lms/djangoapps/student_profile/views.py @@ -70,6 +70,10 @@ def learner_profile_context(logged_in_username, profile_username, user_is_staff) '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}), + 'profile_image_upload_url': reverse('profile_image_upload', kwargs={'username': profile_username}), + 'profile_image_remove_url': reverse('profile_image_remove', kwargs={'username': profile_username}), + 'profile_image_max_bytes': settings.PROFILE_IMAGE_MAX_BYTES, + 'profile_image_min_bytes': settings.PROFILE_IMAGE_MIN_BYTES, '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), diff --git a/lms/static/js/spec/student_account/helpers.js b/lms/static/js/spec/student_account/helpers.js index 12defc5f7b..c4dc04e48a 100644 --- a/lms/static/js/spec/student_account/helpers.js +++ b/lms/static/js/spec/student_account/helpers.js @@ -3,6 +3,8 @@ define(['underscore'], function(_) { var USER_ACCOUNTS_API_URL = '/api/user/v0/accounts/student'; var USER_PREFERENCES_API_URL = '/api/user/v0/preferences/student'; + var IMAGE_UPLOAD_API_URL = '/api/profile_images/v0/staff/upload'; + var IMAGE_REMOVE_API_URL = '/api/profile_images/v0/staff/remove'; var USER_ACCOUNTS_DATA = { username: 'student', @@ -27,9 +29,12 @@ define(['underscore'], function(_) { ['0', 'Option 0'], ['1', 'Option 1'], ['2', 'Option 2'], - ['3', 'Option 3'], + ['3', 'Option 3'] ]; + var IMAGE_MAX_BYTES = 1024 * 1024; + var IMAGE_MIN_BYTES = 100; + var expectLoadingIndicatorIsVisible = function (view, visible) { if (visible) { expect($('.ui-loading-indicator')).not.toHaveClass('is-hidden'); @@ -92,6 +97,10 @@ define(['underscore'], function(_) { return { USER_ACCOUNTS_API_URL: USER_ACCOUNTS_API_URL, USER_PREFERENCES_API_URL: USER_PREFERENCES_API_URL, + IMAGE_UPLOAD_API_URL: IMAGE_UPLOAD_API_URL, + IMAGE_REMOVE_API_URL: IMAGE_REMOVE_API_URL, + IMAGE_MAX_BYTES: IMAGE_MAX_BYTES, + IMAGE_MIN_BYTES: IMAGE_MIN_BYTES, USER_ACCOUNTS_DATA: USER_ACCOUNTS_DATA, USER_PREFERENCES_DATA: USER_PREFERENCES_DATA, FIELD_OPTIONS: FIELD_OPTIONS, 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 index a1d6dd09cd..00826017a5 100644 --- a/lms/static/js/spec/student_profile/learner_profile_view_spec.js +++ b/lms/static/js/spec/student_profile/learner_profile_view_spec.js @@ -6,11 +6,12 @@ define(['backbone', 'jquery', 'underscore', 'js/common_helpers/ajax_helpers', 'j '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' + 'js/student_account/views/account_settings_fields', + 'js/views/message_banner' ], function (Backbone, $, _, AjaxHelpers, TemplateHelpers, Helpers, LearnerProfileHelpers, FieldViews, UserAccountModel, AccountPreferencesModel, LearnerProfileFields, LearnerProfileView, - AccountSettingsFieldViews) { + AccountSettingsFieldViews, MessageBannerView) { 'use strict'; describe("edx.user.LearnerProfileView", function () { @@ -46,6 +47,21 @@ define(['backbone', 'jquery', 'underscore', 'js/common_helpers/ajax_helpers', 'j accountSettingsPageUrl: '/account/settings/' }); + var messageView = new MessageBannerView({ + el: $('.message-banner') + }); + + var profileImageFieldView = new FieldsView.ImageFieldView({ + model: accountSettingsModel, + valueAttribute: "profile_image", + editable: editable, + messageView: messageView, + imageMaxBytes: Helpers.IMAGE_MAX_BYTES, + imageMinBytes: Helpers.IMAGE_MIN_BYTES, + imageUploadUrl: Helpers.IMAGE_UPLOAD_API_URL, + imageRemoveUrl: Helpers.IMAGE_REMOVE_API_URL + }); + var usernameFieldView = new FieldViews.ReadonlyFieldView({ model: accountSettingsModel, valueAttribute: "username", @@ -107,10 +123,12 @@ define(['backbone', 'jquery', 'underscore', 'js/common_helpers/ajax_helpers', 'j }; beforeEach(function () { - setFixtures('

Loading

'); + setFixtures('

Loading

'); TemplateHelpers.installTemplate('templates/fields/field_readonly'); TemplateHelpers.installTemplate('templates/fields/field_dropdown'); TemplateHelpers.installTemplate('templates/fields/field_textarea'); + TemplateHelpers.installTemplate('templates/fields/field_image'); + TemplateHelpers.installTemplate('templates/message_banner'); TemplateHelpers.installTemplate('templates/student_profile/learner_profile'); }); 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 ca8d9e4b29..e847d0ba88 100644 --- a/lms/static/js/student_account/models/user_account_model.js +++ b/lms/static/js/student_account/models/user_account_model.js @@ -22,6 +22,7 @@ bio: null, language_proficiencies: [], requires_parental_consent: true, + profile_image: null, default_public_account_fields: [] }, @@ -39,6 +40,15 @@ return response; }, + hasProfileImage: function () { + var profile_image = this.get('profile_image'); + return (_.isObject(profile_image) && profile_image['has_image'] === true); + }, + + profileImageUrl: function () { + return this.get('profile_image')['image_url_large']; + }, + isAboveMinimumAge: function() { var isBirthDefined = !(_.isUndefined(this.get('year_of_birth')) || _.isNull(this.get('year_of_birth'))); return isBirthDefined && !(this.get("requires_parental_consent")); diff --git a/lms/static/js/student_profile/views/learner_profile_factory.js b/lms/static/js/student_profile/views/learner_profile_factory.js index 2f36c92ce7..8f3a9f26a1 100644 --- a/lms/static/js/student_profile/views/learner_profile_factory.js +++ b/lms/static/js/student_profile/views/learner_profile_factory.js @@ -7,9 +7,10 @@ '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, Logger, AccountSettingsModel, AccountPreferencesModel, FieldsView, - LearnerProfileFieldsView, LearnerProfileView, AccountSettingsFieldViews) { + 'js/student_account/views/account_settings_fields', + 'js/views/message_banner' + ], function (gettext, $, _, Backbone, AccountSettingsModel, AccountPreferencesModel, FieldsView, + LearnerProfileFieldsView, LearnerProfileView, AccountSettingsFieldViews, MessageBannerView) { return function (options) { @@ -25,6 +26,10 @@ var editable = options.own_profile ? 'toggle' : 'never'; + var messageView = new MessageBannerView({ + el: $('.message-banner') + }); + var accountPrivacyFieldView = new LearnerProfileFieldsView.AccountPrivacyFieldView({ model: accountPreferencesModel, required: true, @@ -40,6 +45,17 @@ accountSettingsPageUrl: options.account_settings_page_url }); + var profileImageFieldView = new LearnerProfileFieldsView.ProfileImageFieldView({ + model: accountSettingsModel, + valueAttribute: "profile_image", + editable: editable === 'toggle', + messageView: messageView, + imageMaxBytes: options['profile_image_max_bytes'], + imageMinBytes: options['profile_image_min_bytes'], + imageUploadUrl: options['profile_image_upload_url'], + imageRemoveUrl: options['profile_image_remove_url'] + }); + var usernameFieldView = new FieldsView.ReadonlyFieldView({ model: accountSettingsModel, valueAttribute: "username", @@ -47,7 +63,6 @@ }); var sectionOneFieldViews = [ - usernameFieldView, new FieldsView.DropdownFieldView({ model: accountSettingsModel, required: true, @@ -94,6 +109,7 @@ accountSettingsModel: accountSettingsModel, preferencesModel: accountPreferencesModel, accountPrivacyFieldView: accountPrivacyFieldView, + profileImageFieldView: profileImageFieldView, usernameFieldView: usernameFieldView, sectionOneFieldViews: sectionOneFieldViews, sectionTwoFieldViews: sectionTwoFieldViews diff --git a/lms/static/js/student_profile/views/learner_profile_fields.js b/lms/static/js/student_profile/views/learner_profile_fields.js index bae5aa7116..e5c6e13d61 100644 --- a/lms/static/js/student_profile/views/learner_profile_fields.js +++ b/lms/static/js/student_profile/views/learner_profile_fields.js @@ -48,6 +48,64 @@ } }); + LearnerProfileFieldViews.ProfileImageFieldView = FieldViews.ImageFieldView.extend({ + + imageUrl: function () { + return this.model.profileImageUrl(); + }, + + imageAltText: function () { + return interpolate_text(gettext("Profile image for {username}"), {username: this.model.get('username')}); + }, + + imageChangeSucceeded: function (e, data) { + var view = this; + // Update model to get the latest urls of profile image. + this.model.fetch().done(function () { + view.setCurrentStatus(''); + }).fail(function () { + view.showErrorMessage(view.errorMessage); + }); + }, + + imageChangeFailed: function (e, data) { + this.setCurrentStatus(''); + if (_.contains([400, 404], data.jqXHR.status)) { + try { + var errors = JSON.parse(data.jqXHR.responseText); + this.showErrorMessage(errors.user_message); + } catch (error) { + this.showErrorMessage(this.errorMessage); + } + } else { + this.showErrorMessage(this.errorMessage); + } + this.render(); + }, + + showErrorMessage: function (message) { + this.options.messageView.showMessage(message); + }, + + isEditingAllowed: function () { + return this.model.isAboveMinimumAge(); + }, + + isShowingPlaceholder: function () { + return !this.model.hasProfileImage(); + }, + + clickedRemoveButton: function (e, data) { + this.options.messageView.hideMessage(); + this._super(e, data); + }, + + fileSelected: function (e, data) { + this.options.messageView.hideMessage(); + this._super(e, data); + } + }); + 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 index f52a9262b8..36f477b6d7 100644 --- a/lms/static/js/student_profile/views/learner_profile_view.js +++ b/lms/static/js/student_profile/views/learner_profile_view.js @@ -24,7 +24,6 @@ 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.ownProfile, showFullProfile: this.showFullProfile() })); @@ -48,6 +47,11 @@ this.$('.profile-section-one-fields').append(this.options.usernameFieldView.render().el); + var imageView = this.options.profileImageFieldView; + imageView.undelegateEvents(); + this.$('.profile-image-field').append(imageView.render().el); + imageView.delegateEvents(); + if (this.showFullProfile()) { _.each(this.options.sectionOneFieldViews, function (fieldView) { fieldView.undelegateEvents(); diff --git a/lms/static/js/views/fields.js b/lms/static/js/views/fields.js index b14b263c0c..2a8c1b7619 100644 --- a/lms/static/js/views/fields.js +++ b/lms/static/js/views/fields.js @@ -499,6 +499,207 @@ } }); + FieldViews.ImageFieldView = FieldViews.FieldView.extend({ + + fieldType: 'image', + + templateSelector: '#field_image-tpl', + uploadButtonSelector: '.upload-button-input', + + titleAdd: gettext("Upload an image"), + titleEdit: gettext("Change image"), + titleRemove: gettext("Remove"), + + titleUploading: gettext("Uploading"), + titleRemoving: gettext("Removing"), + + titleImageAlt: '', + + iconUpload: '', + iconRemove: '', + iconProgress: '', + + errorMessage: gettext("An error has occurred. Refresh the page, and then try again."), + + events: { + 'click .u-field-upload-button': 'clickedUploadButton', + 'click .u-field-remove-button': 'clickedRemoveButton' + }, + + initialize: function (options) { + this._super(options); + _.bindAll(this, 'render', 'imageChangeSucceeded', 'imageChangeFailed', 'fileSelected', + 'watchForPageUnload', 'onBeforeUnload'); + this.listenTo(this.model, "change:" + this.options.valueAttribute, this.render); + }, + + render: function () { + this.$el.html(this.template({ + id: this.options.valueAttribute, + imageUrl: _.result(this, 'imageUrl'), + imageAltText: _.result(this, 'imageAltText'), + uploadButtonIcon: _.result(this, 'iconUpload'), + uploadButtonTitle: _.result(this, 'uploadButtonTitle'), + removeButtonIcon: _.result(this, 'iconRemove'), + removeButtonTitle: _.result(this, 'removeButtonTitle') + })); + this.updateButtonsVisibility(); + this.watchForPageUnload(); + return this; + }, + + showErrorMessage: function () { + }, + + imageUrl: function () { + return ''; + }, + + uploadButtonTitle: function () { + if (this.isShowingPlaceholder()) { + return _.result(this, 'titleAdd') + } else { + return _.result(this, 'titleEdit') + } + }, + + removeButtonTitle: function () { + return this.titleRemove; + }, + + isEditingAllowed: function () { + return true + }, + + isShowingPlaceholder: function () { + return false; + }, + + setUploadButtonVisibility: function (state) { + this.$('.u-field-upload-button').css('display', state); + }, + + setRemoveButtonVisibility: function (state) { + this.$('.u-field-remove-button').css('display', state); + }, + + updateButtonsVisibility: function () { + if (!this.isEditingAllowed() || !this.options.editable) { + this.setUploadButtonVisibility('none'); + } + + if (this.isShowingPlaceholder() || !this.options.editable) { + this.setRemoveButtonVisibility('none'); + } + }, + + clickedUploadButton: function () { + $(this.uploadButtonSelector).fileupload({ + url: this.options.imageUploadUrl, + type: 'POST', + add: this.fileSelected, + done: this.imageChangeSucceeded, + fail: this.imageChangeFailed + }); + }, + + clickedRemoveButton: function () { + var view = this; + this.setCurrentStatus('removing'); + this.setUploadButtonVisibility('none'); + this.showRemovalInProgressMessage(); + $.ajax({ + type: 'POST', + url: this.options.imageRemoveUrl, + success: function (data, status, xhr) { + view.imageChangeSucceeded(); + }, + error: function (xhr, status, error) { + view.imageChangeFailed(); + } + }); + }, + + imageChangeSucceeded: function (e, data) { + this.render(); + }, + + imageChangeFailed: function (e, data) { + }, + + fileSelected: function (e, data) { + if (this.validateImageSize(data.files[0].size)) { + data.formData = {file: data.files[0]}; + this.setCurrentStatus('uploading'); + this.setRemoveButtonVisibility('none'); + this.showUploadInProgressMessage(); + data.submit(); + } + }, + + validateImageSize: function (imageBytes) { + var humanReadableSize; + if (imageBytes < this.options.imageMinBytes) { + humanReadableSize = this.bytesToHumanReadable(this.options.imageMinBytes); + this.showErrorMessage(interpolate_text(gettext("Your image must be at least {size} in size."), {size: humanReadableSize})); + return false; + } else if (imageBytes > this.options.imageMaxBytes) { + humanReadableSize = this.bytesToHumanReadable(this.options.imageMaxBytes); + this.showErrorMessage(interpolate_text(gettext("Your image must be smaller than {size} in size."), {size: humanReadableSize})); + return false; + } + return true; + }, + + showUploadInProgressMessage: function () { + this.$('.u-field-upload-button').css('opacity', 1); + this.$('.upload-button-icon').html(this.iconProgress); + this.$('.upload-button-title').html(this.titleUploading); + }, + + showRemovalInProgressMessage: function () { + this.$('.u-field-remove-button').css('opacity', 1); + this.$('.remove-button-icon').html(this.iconProgress); + this.$('.remove-button-title').html(this.titleRemoving); + }, + + setCurrentStatus: function (status) { + this.$('.image-wrapper').attr('data-status', status); + }, + + getCurrentStatus: function () { + return this.$('.image-wrapper').attr('data-status'); + }, + + inProgress: function() { + var status = this.getCurrentStatus(); + return _.isUndefined(status) ? false : true; + }, + + watchForPageUnload: function () { + $(window).on('beforeunload', this.onBeforeUnload); + }, + + onBeforeUnload: function () { + var status = this.getCurrentStatus(); + if (status === 'uploading') { + return gettext("Upload is in progress. To avoid errors, stay on this page until the process is complete."); + } else if (status === 'removing') { + return gettext("Removal is in progress. To avoid errors, stay on this page until the process is complete."); + } + }, + + bytesToHumanReadable: function (size) { + var units = ['Bytes', 'KB', 'MB']; + var i = 0; + while(size >= 1024) { + size /= 1024; + ++i; + } + return size.toFixed(1)*1 + ' ' + units[i]; + } + }); + return FieldViews; }); }).call(this, define || RequireJS.define); diff --git a/lms/static/js/views/message_banner.js b/lms/static/js/views/message_banner.js new file mode 100644 index 0000000000..6227f8d7db --- /dev/null +++ b/lms/static/js/views/message_banner.js @@ -0,0 +1,32 @@ +;(function (define, undefined) { + 'use strict'; + define([ + 'gettext', 'jquery', 'underscore', 'backbone' + ], function (gettext, $, _, Backbone) { + + var MessageBannerView = Backbone.View.extend({ + + initialize: function (options) { + this.template = _.template($('#message_banner-tpl').text()); + }, + + render: function () { + this.$el.html(this.template({ + message: this.message + })); + return this; + }, + + showMessage: function (message) { + this.message = message; + this.render(); + }, + + hideMessage: function () { + this.$el.html(''); + } + }); + + return MessageBannerView; + }) +}).call(this, define || RequireJS.define); diff --git a/lms/static/sass/views/_learner-profile.scss b/lms/static/sass/views/_learner-profile.scss index b02a663b45..06ea05ea3c 100644 --- a/lms/static/sass/views/_learner-profile.scss +++ b/lms/static/sass/views/_learner-profile.scss @@ -7,7 +7,7 @@ // * +Settings Section .view-profile { - $profile-photo-dimension: 120px; + $profile-image-dimension: 120px; .content-wrapper { background-color: $white; @@ -23,6 +23,75 @@ width: ($baseline*5); } + .profile-image-field { + @include float(left); + + button { + background: transparent !important; + border: none !important; + padding: 0; + } + + .u-field-image { + padding-top: 0; + } + + .image-wrapper { + width: $profile-image-dimension; + position: relative; + + .image-frame { + position: relative; + width: 120px; + height: 120px; + } + + .u-field-upload-button { + width: 120px; + height: 120px; + position: absolute; + top: 0; + opacity: 0; + + i { + color: $white; + } + } + + .upload-button-icon, .upload-button-title { + text-align: center; + transform: translateY(45px); + display: block; + color: $white; + } + + .upload-button-input { + width: 120px; + height: 100%; + position: absolute; + top: 0; + left: 0; + opacity: 0; + cursor: pointer; + } + + .u-field-remove-button { + width: 120px; + height: 20px; + opacity: 0; + position: relative; + margin-top: 2px; + text-align: center; + } + + &:hover { + .u-field-upload-button, .u-field-remove-button { + opacity: 1; + } + } + } + } + .wrapper-profile { min-height: 200px; @@ -77,14 +146,6 @@ 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 { diff --git a/lms/templates/fields/field_image.underscore b/lms/templates/fields/field_image.underscore new file mode 100644 index 0000000000..5c345ada5e --- /dev/null +++ b/lms/templates/fields/field_image.underscore @@ -0,0 +1,15 @@ +
+ <%=imageAltText%> +
+ + + +
+
\ No newline at end of file diff --git a/lms/templates/message_banner.underscore b/lms/templates/message_banner.underscore new file mode 100644 index 0000000000..c2cd316ccf --- /dev/null +++ b/lms/templates/message_banner.underscore @@ -0,0 +1,9 @@ +
+
+
+
+

<%-message%>

+
+
+
+
\ No newline at end of file diff --git a/lms/templates/student_profile/learner_profile.html b/lms/templates/student_profile/learner_profile.html index 17be327cad..29475aa614 100644 --- a/lms/templates/student_profile/learner_profile.html +++ b/lms/templates/student_profile/learner_profile.html @@ -10,27 +10,36 @@ <%block name="bodyclass">view-profile <%block name="header_extras"> - % for template_name in ["field_dropdown", "field_textarea", "field_readonly"]: + % for template_name in ["field_dropdown", "field_image", "field_textarea", "field_readonly"]: % endfor - % for template_name in ["learner_profile",]: + % for template_name in ["learner_profile"]: % endfor + + % for template_name in ["message_banner"]: + + % endfor +
-

${_("Loading")}

+

${_("Loading")}

<%block name="headextra"> <%static:css group='style-course'/> + +