From 059985b9ec2cbf8313776cb4c6060b4e5111d6b2 Mon Sep 17 00:00:00 2001
From: Kevin Kim <kkim@edx.org>
Date: Mon, 6 Jun 2016 14:24:18 +0000
Subject: [PATCH] Added time zone field to user account settings, currently hidden behind a feature flag.

---
 AUTHORS                                                             |  2 +-
 common/djangoapps/util/date_utils.py                                | 18 ++++++++++++++----
 common/test/acceptance/tests/lms/test_account_settings.py           |  3 ++-
 lms/djangoapps/student_account/test/test_views.py                   | 47 +++++++++++++++++++++++++++++++++++++----------
 lms/djangoapps/student_account/views.py                             |  8 ++++----
 lms/envs/bok_choy.py                                                |  3 +++
 lms/envs/common.py                                                  |  3 +++
 lms/static/js/spec/student_account/account_settings_factory_spec.js |  5 ++++-
 lms/static/js/spec/student_account/helpers.js                       | 12 ++++++++++--
 lms/static/js/spec/views/fields_helpers.js                          |  3 ++-
 lms/static/js/student_account/views/account_settings_factory.js     | 16 ++++++++++++++++
 lms/static/js/student_account/views/account_settings_fields.js      | 28 ++++++++++++++++++----------
 lms/static/js/student_account/views/account_settings_view.js        |  9 ++++++---
 lms/static/js/student_profile/views/learner_profile_fields.js       | 36 +++++++++++++++++++++++-------------
 lms/static/js/student_profile/views/learner_profile_view.js         |  6 +++---
 lms/static/js/views/fields.js                                       | 73 +++++++++++++++++++++++++++++++++++++++++++++++++------------------------
 openedx/core/djangoapps/user_api/accounts/serializers.py            |  1 -
 openedx/core/djangoapps/user_api/models.py                          |  7 +++++++
 openedx/core/djangoapps/user_api/preferences/api.py                 | 13 +++++++++++++
 openedx/core/djangoapps/user_api/preferences/tests/test_views.py    | 15 ++++++++++++++-
 20 files changed, 229 insertions(+), 79 deletions(-)

diff --git a/AUTHORS b/AUTHORS
index 54fea9d..e759b59 100644
--- a/AUTHORS
+++ b/AUTHORS
@@ -272,4 +272,4 @@ Brian Jacobel <bjacobel@edx.org>
 Sigberto Alarcon <salarcon@stanford.edu>
 Sofiya Semenova <ssemenova@edx.org>
 Alisan Tang <atang@edx.org>
-
+Kevin Kim <kkim@edx.org>
diff --git a/common/djangoapps/util/date_utils.py b/common/djangoapps/util/date_utils.py
index d4bfff6..0156c1a 100644
--- a/common/djangoapps/util/date_utils.py
+++ b/common/djangoapps/util/date_utils.py
@@ -5,8 +5,9 @@ Convenience methods for working with datetime objects
 from datetime import datetime, timedelta
 import re
 
-from pytz import timezone, UTC, UnknownTimeZoneError
+from django.utils.timezone import now
 from django.utils.translation import pgettext, ugettext
+from pytz import timezone, utc, UnknownTimeZoneError
 
 
 def get_default_time_display(dtime):
@@ -52,7 +53,7 @@ def get_time_display(dtime, format_string=None, coerce_tz=None):
         try:
             to_tz = timezone(coerce_tz)
         except UnknownTimeZoneError:
-            to_tz = UTC
+            to_tz = utc
         dtime = to_tz.normalize(dtime.astimezone(to_tz))
     if dtime is None or format_string is None:
         return get_default_time_display(dtime)
@@ -62,6 +63,15 @@ def get_time_display(dtime, format_string=None, coerce_tz=None):
         return get_default_time_display(dtime)
 
 
+def get_formatted_time_zone(time_zone):
+    """
+    Returns a formatted time zone (e.g. 'Asia/Tokyo (JST +0900)') for user account settings time zone drop down
+    """
+    abbr = get_time_display(now(), '%Z', time_zone)
+    offset = get_time_display(now(), '%z', time_zone)
+    return "{name} ({abbr}, UTC{offset})".format(name=time_zone, abbr=abbr, offset=offset).replace("_", " ")
+
+
 def almost_same_datetime(dt1, dt2, allowed_delta=timedelta(minutes=1)):
     """
     Returns true if these are w/in a minute of each other. (in case secs saved to db
@@ -78,7 +88,7 @@ def to_timestamp(datetime_value):
     Convert a datetime into a timestamp, represented as the number
     of seconds since January 1, 1970 UTC.
     """
-    return int((datetime_value - datetime(1970, 1, 1, tzinfo=UTC)).total_seconds())
+    return int((datetime_value - datetime(1970, 1, 1, tzinfo=utc)).total_seconds())
 
 
 def from_timestamp(timestamp):
@@ -89,7 +99,7 @@ def from_timestamp(timestamp):
     If the timestamp cannot be converted, returns None instead.
     """
     try:
-        return datetime.utcfromtimestamp(int(timestamp)).replace(tzinfo=UTC)
+        return datetime.utcfromtimestamp(int(timestamp)).replace(tzinfo=utc)
     except (ValueError, TypeError):
         return None
 
diff --git a/common/test/acceptance/tests/lms/test_account_settings.py b/common/test/acceptance/tests/lms/test_account_settings.py
index 7579a84..7b8f922 100644
--- a/common/test/acceptance/tests/lms/test_account_settings.py
+++ b/common/test/acceptance/tests/lms/test_account_settings.py
@@ -165,7 +165,8 @@ class AccountSettingsPageTest(AccountSettingsTestMixin, WebAppTest):
                     'Email Address',
                     'Password',
                     'Language',
-                    'Country or Region'
+                    'Country or Region',
+                    'Time Zone',
                 ]
             },
             {
diff --git a/lms/djangoapps/student_account/test/test_views.py b/lms/djangoapps/student_account/test/test_views.py
index 7815d24..c410630 100644
--- a/lms/djangoapps/student_account/test/test_views.py
+++ b/lms/djangoapps/student_account/test/test_views.py
@@ -1,8 +1,8 @@
 # -*- coding: utf-8 -*-
 """ Tests for student account views. """
 
+from copy import copy
 import re
-from nose.plugins.attrib import attr
 from unittest import skipUnless
 from urllib import urlencode
 
@@ -18,22 +18,23 @@ from django.test import TestCase
 from django.test.utils import override_settings
 from django.http import HttpRequest
 from edx_rest_api_client import exceptions
+from nose.plugins.attrib import attr
 
-from course_modes.models import CourseMode
 from commerce.models import CommerceConfiguration
 from commerce.tests import TEST_API_URL, TEST_API_SIGNING_KEY, factories
 from commerce.tests.mocks import mock_get_orders
+from course_modes.models import CourseMode
 from openedx.core.djangoapps.programs.tests.mixins import ProgramsApiConfigMixin
 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 openedx.core.djangolib.js_utils import dump_js_escaped_json
 from openedx.core.djangolib.testing.utils import CacheIsolationTestCase
+from openedx.core.djangoapps.theming.tests.test_util import with_edx_domain_context
 from student.tests.factories import UserFactory
 from student_account.views import account_settings_context, get_user_orders
 from third_party_auth.tests.testutil import simulate_running_pipeline, ThirdPartyAuthTestMixin
 from util.testing import UrlResetMixin
 from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
-from openedx.core.djangoapps.theming.tests.test_util import with_edx_domain_context
 
 
 @ddt.ddt
@@ -463,6 +464,10 @@ class AccountSettingsViewTest(ThirdPartyAuthTestMixin, TestCase, ProgramsApiConf
         'preferred_language',
     ]
 
+    HIDDEN_FIELDS = [
+        'time_zone',
+    ]
+
     @mock.patch("django.conf.settings.MESSAGE_STORAGE", 'django.contrib.messages.storage.cookie.CookieStorage')
     def setUp(self):
         super(AccountSettingsViewTest, self).setUp()
@@ -507,13 +512,35 @@ class AccountSettingsViewTest(ThirdPartyAuthTestMixin, TestCase, ProgramsApiConf
         self.assertEqual(context['auth']['providers'][0]['name'], 'Facebook')
         self.assertEqual(context['auth']['providers'][1]['name'], 'Google')
 
-    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)
+    def test_hidden_fields_not_visible(self):
+        """
+        Test that hidden fields are not visible when disabled.
+        """
+        temp_features = copy(settings.FEATURES)
+        temp_features['ENABLE_TIME_ZONE_PREFERENCE'] = False
+        with self.settings(FEATURES=temp_features):
+            view_path = reverse('account_settings')
+            response = self.client.get(path=view_path)
+
+            for attribute in self.FIELDS:
+                self.assertIn(attribute, response.content)
+            for attribute in self.HIDDEN_FIELDS:
+                self.assertIn('"%s": {"enabled": false' % (attribute), response.content)
+
+    def test_hidden_fields_are_visible(self):
+        """
+        Test that hidden fields are visible when enabled.
+        """
+        temp_features = copy(settings.FEATURES)
+        temp_features['ENABLE_TIME_ZONE_PREFERENCE'] = True
+        with self.settings(FEATURES=temp_features):
+            view_path = reverse('account_settings')
+            response = self.client.get(path=view_path)
+
+            for attribute in self.FIELDS:
+                self.assertIn(attribute, response.content)
+            for attribute in self.HIDDEN_FIELDS:
+                self.assertIn('"%s": {"enabled": true' % (attribute), response.content)
 
     def test_header_with_programs_listing_enabled(self):
         """
diff --git a/lms/djangoapps/student_account/views.py b/lms/djangoapps/student_account/views.py
index ae62fd1..f57798e 100644
--- a/lms/djangoapps/student_account/views.py
+++ b/lms/djangoapps/student_account/views.py
@@ -31,6 +31,7 @@ from openedx.core.djangoapps.programs.models import ProgramsApiConfig
 from openedx.core.djangoapps.theming.helpers import is_request_in_themed_site, get_value as get_themed_value
 from openedx.core.djangoapps.user_api.accounts.api import request_password_change
 from openedx.core.djangoapps.user_api.errors import UserNotFound
+from openedx.core.djangoapps.user_api.models import UserPreference
 from openedx.core.lib.edx_api_utils import get_edx_api_data
 from student.models import UserProfile
 from student.views import (
@@ -42,12 +43,8 @@ import third_party_auth
 from third_party_auth import pipeline
 from third_party_auth.decorators import xframe_allow_whitelisted
 from util.bad_request_rate_limiter import BadRequestRateLimiter
-from openedx.core.djangoapps.theming.helpers import is_request_in_themed_site, get_value as get_themed_value
-from openedx.core.djangoapps.user_api.accounts.api import request_password_change
-from openedx.core.djangoapps.user_api.errors import UserNotFound
 from util.date_utils import strftime_localized
 
-
 AUDIT_LOG = logging.getLogger("audit")
 log = logging.getLogger(__name__)
 
@@ -451,6 +448,9 @@ def account_settings_context(request):
                 'options': year_of_birth_options,
             }, 'preferred_language': {
                 'options': all_languages(),
+            }, 'time_zone': {
+                'options': UserPreference.TIME_ZONE_CHOICES,
+                'enabled': settings.FEATURES.get('ENABLE_TIME_ZONE_PREFERENCE'),
             }
         },
         'platform_name': get_themed_value('PLATFORM_NAME', settings.PLATFORM_NAME),
diff --git a/lms/envs/bok_choy.py b/lms/envs/bok_choy.py
index 724506d..70e6919 100644
--- a/lms/envs/bok_choy.py
+++ b/lms/envs/bok_choy.py
@@ -164,6 +164,9 @@ FEATURES['ENABLE_DASHBOARD_SEARCH'] = True
 # Enable support for OpenBadges accomplishments
 FEATURES['ENABLE_OPENBADGES'] = True
 
+# Enable time zone field in account settings. Will be removed in Ticket #TNL-4750.
+FEATURES['ENABLE_TIME_ZONE_PREFERENCE'] = True
+
 # Use MockSearchEngine as the search engine for test scenario
 SEARCH_ENGINE = "search.tests.mock_search_engine.MockSearchEngine"
 # Path at which to store the mock index
diff --git a/lms/envs/common.py b/lms/envs/common.py
index 5ea1eca..1df9ed2 100644
--- a/lms/envs/common.py
+++ b/lms/envs/common.py
@@ -363,6 +363,9 @@ FEATURES = {
     # lives in the Extended table, saving the frontend from
     # making multiple queries.
     'ENABLE_READING_FROM_MULTIPLE_HISTORY_TABLES': True,
+
+    # WIP -- will be removed in Ticket #TNL-4750.
+    'ENABLE_TIME_ZONE_PREFERENCE': False,
 }
 
 # Ignore static asset files on import which match this pattern
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 8e01a76..67f2726 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
@@ -30,6 +30,9 @@ define(['backbone',
                     'options': Helpers.FIELD_OPTIONS
                 }, 'preferred_language': {
                     'options': Helpers.FIELD_OPTIONS
+                }, 'time_zone': {
+                    'options': Helpers.FIELD_OPTIONS,
+                    'enabled': false
                 }
             };
 
@@ -148,7 +151,7 @@ define(['backbone',
 
                 var sectionsData = accountSettingsView.options.tabSections.aboutTabSections;
 
-                expect(sectionsData[0].fields.length).toBe(6);
+                expect(sectionsData[0].fields.length).toBe(7);
 
                 var textFields = [sectionsData[0].fields[1], sectionsData[0].fields[2]];
                 for (i = 0; i < textFields.length ; i++) {
diff --git a/lms/static/js/spec/student_account/helpers.js b/lms/static/js/spec/student_account/helpers.js
index 765c2ac..5fb0584 100644
--- a/lms/static/js/spec/student_account/helpers.js
+++ b/lms/static/js/spec/student_account/helpers.js
@@ -34,7 +34,8 @@ define(['underscore'], function(_) {
     };
 
     var DEFAULT_USER_PREFERENCES_DATA = {
-        'pref-lang': '2'
+        'pref-lang': '2',
+        'time_zone': null
     };
 
     var createUserPreferencesData = function(options) {
@@ -100,7 +101,14 @@ define(['underscore'], function(_) {
             if (fieldsAreRendered === false) {
                 expect(sectionFieldElements.length).toBe(0);
             } else {
-                expect(sectionFieldElements.length).toBe(sectionsData[sectionIndex].fields.length);
+                var visible_count = 0;
+                _.each(sectionsData[sectionIndex].fields, function(field) {
+                    if (field.view.enabled) {
+                        visible_count++;
+                    }
+                });
+
+                expect(sectionFieldElements.length).toBe(visible_count);
 
                 _.each(sectionFieldElements, function (sectionFieldElement, fieldIndex) {
                     expectElementContainsField(sectionFieldElement, sectionsData[sectionIndex].fields[fieldIndex]);
diff --git a/lms/static/js/spec/views/fields_helpers.js b/lms/static/js/spec/views/fields_helpers.js
index 739a288..dad147c 100644
--- a/lms/static/js/spec/views/fields_helpers.js
+++ b/lms/static/js/spec/views/fields_helpers.js
@@ -1,11 +1,12 @@
 define(['backbone',
         'jquery',
         'underscore',
+        'edx-ui-toolkit/js/utils/html-utils', 
         'edx-ui-toolkit/js/utils/spec-helpers/ajax-helpers',
         'common/js/spec_helpers/template_helpers',
         'js/views/fields',
         'string_utils'],
-    function (Backbone, $, _, AjaxHelpers, TemplateHelpers, FieldViews) {
+    function (Backbone, $, _, HtmlUtils, AjaxHelpers, TemplateHelpers, FieldViews) {
         'use strict';
 
         var API_URL = '/api/end_point/v1';
diff --git a/lms/static/js/student_account/views/account_settings_factory.js b/lms/static/js/student_account/views/account_settings_factory.js
index f3613a4..eab744e 100644
--- a/lms/static/js/student_account/views/account_settings_factory.js
+++ b/lms/static/js/student_account/views/account_settings_factory.js
@@ -109,6 +109,22 @@
                                 options: fieldsData.country.options,
                                 persistChanges: true
                             })
+                        },
+                        {
+                            view: new AccountSettingsFieldViews.DropdownFieldView({
+                                model: userPreferencesModel,
+                                required: true,
+                                title: gettext('Time Zone'),
+                                valueAttribute: 'time_zone',
+                                enabled: fieldsData.time_zone.enabled,
+                                helpMessage: gettext(
+                                    'Select the time zone for displaying course dates. If you do not specify a ' +
+                                    'time zone here, course dates, including assignment deadlines, are displayed in ' +
+                                    'Coordinated Universal Time (UTC).'
+                                ),
+                                options: fieldsData.time_zone.options,
+                                persistChanges: true
+                            })
                         }
                     ]
                 },
diff --git a/lms/static/js/student_account/views/account_settings_fields.js b/lms/static/js/student_account/views/account_settings_fields.js
index 6cd7979..33dccc9 100644
--- a/lms/static/js/student_account/views/account_settings_fields.js
+++ b/lms/static/js/student_account/views/account_settings_fields.js
@@ -41,10 +41,13 @@
             EmailFieldView: FieldViews.TextFieldView.extend({
                 fieldTemplate: field_text_account_template,
                 successMessage: function () {
-                    return this.indicators.success + StringUtils.interpolate(
+                    return HtmlUtils.joinHtml(
+                        this.indicators.success,
+                        StringUtils.interpolate(
                             gettext('We\'ve sent a confirmation message to {new_email_address}. Click the link in the message to update your email address.'), /* jshint ignore:line */
                             {'new_email_address': this.fieldValue()}
-                        );
+                        )
+                    );
                 }
             }),
             LanguagePreferenceFieldView: FieldViews.DropdownFieldView.extend({
@@ -65,8 +68,10 @@
                         },
                         error: function () {
                             view.showNotificationMessage(
-                                view.indicators.error +
-                                gettext('You must sign out and sign back in before your language changes take effect.')
+                                HtmlUtils.joinHtml(
+                                    view.indicators.error,
+                                    gettext('You must sign out and sign back in before your language changes take effect.') // jshint ignore:line
+                                )
                             );
                         }
                     });
@@ -106,10 +111,13 @@
                     });
                 },
                 successMessage: function () {
-                    return this.indicators.success + StringUtils.interpolate(
+                    return HtmlUtils.joinHtml(
+                        this.indicators.success,
+                        StringUtils.interpolate(
                             gettext('We\'ve sent a message to {email_address}. Click the link in the message to reset your password.'), /* jshint ignore:line */
                             {'email_address': this.model.get(this.options.emailAttribute)}
-                        );
+                        )
+                    );
                 }
             }),
             LanguageProficienciesFieldView: FieldViews.DropdownFieldView.extend({
@@ -169,7 +177,7 @@
                         );
                     }
 
-                    this.$el.html(this.template({
+                    HtmlUtils.setHtml(this.$el, HtmlUtils.template(this.fieldTemplate)({
                         id: this.options.valueAttribute,
                         title: this.options.title,
                         screenReaderTitle: screenReaderTitle,
@@ -220,12 +228,12 @@
                     });
                 },
                 inProgressMessage: function () {
-                    return this.indicators.inProgress + (
+                    return HtmlUtils.joinHtml(this.indicators.inProgress, (
                         this.options.connected ? gettext('Unlinking') : gettext('Linking')
-                    );
+                    ));
                 },
                 successMessage: function () {
-                    return this.indicators.success + gettext('Successfully unlinked.');
+                    return HtmlUtils.joinHtml(this.indicators.success, gettext('Successfully unlinked.'));
                 }
             }),
             
diff --git a/lms/static/js/student_account/views/account_settings_view.js b/lms/static/js/student_account/views/account_settings_view.js
index bf55d19..e4392f7 100644
--- a/lms/static/js/student_account/views/account_settings_view.js
+++ b/lms/static/js/student_account/views/account_settings_view.js
@@ -5,9 +5,10 @@
         'jquery',
         'underscore',
         'backbone',
+        'edx-ui-toolkit/js/utils/html-utils',
         'js/student_account/views/account_section_view',
         'text!templates/student_account/account_settings.underscore'
-    ], function (gettext, $, _, Backbone, AccountSectionView, accountSettingsTemplate) {
+    ], function (gettext, $, _, Backbone, HtmlUtils, AccountSectionView, accountSettingsTemplate) {
 
         var AccountSettingsView = Backbone.View.extend({
 
@@ -28,7 +29,7 @@
             },
 
             render: function () {
-                this.$el.html(_.template(accountSettingsTemplate)({
+                HtmlUtils.setHtml(this.$el, HtmlUtils.template(accountSettingsTemplate)({
                     accountSettingsTabs: this.accountSettingsTabs
                 }));
                 this.renderSection(this.options.tabSections[this.activeTab]);
@@ -67,7 +68,9 @@
 
                 _.each(view.$('.account-settings-section-body'), function (sectionEl, index) {
                     _.each(view.options.tabSections[view.activeTab][index].fields, function (field) {
-                        $(sectionEl).append(field.view.render().el);
+                        if (field.view.enabled) {
+                            $(sectionEl).append(field.view.render().el);
+                        }
                     });
                 });
                 return this;
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 4548c08..db508d8 100644
--- a/lms/static/js/student_profile/views/learner_profile_fields.js
+++ b/lms/static/js/student_profile/views/learner_profile_fields.js
@@ -1,8 +1,9 @@
 ;(function (define, undefined) {
     'use strict';
     define([
-        'gettext', 'jquery', 'underscore', 'backbone', 'js/views/fields', 'js/views/image_field', 'backbone-super'
-    ], function (gettext, $, _, Backbone, FieldViews, ImageFieldView) {
+        'gettext', 'jquery', 'underscore', 'backbone', 'edx-ui-toolkit/js/utils/string-utils',
+        'edx-ui-toolkit/js/utils/html-utils', 'js/views/fields', 'js/views/image_field', 'backbone-super'
+    ], function (gettext, $, _, Backbone, StringUtils, HtmlUtils, FieldViews, ImageFieldView) {
 
         var LearnerProfileFieldViews = {};
 
@@ -16,22 +17,31 @@
             },
 
             showNotificationMessage: function () {
-                var accountSettingsLink = '<a href="' + this.options.accountSettingsPageUrl + '">' + gettext('Account Settings page.') + '</a>';
+                var accountSettingsLink = HtmlUtils.joinHtml(
+                    HtmlUtils.interpolateHtml(
+                        HtmlUtils.HTML('<a href="{settings_url}">'), {settings_url: this.options.accountSettingsPageUrl}
+                    ),
+                    gettext('Account Settings page.'),
+                    HtmlUtils.HTML('</a>')
+                );
                 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': accountSettingsLink}
-                    ));
+                    this._super(
+                        HtmlUtils.interpolateHtml(
+                            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}"), // jshint ignore:line
+                            {'account_settings_page_link':accountSettingsLink}
+                        )
+                    );
                 } 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': accountSettingsLink}
-                    ));
+                    this._super(
+                        HtmlUtils.interpolateHtml(
+                            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}'), // jshint ignore:line
+                            {'account_settings_page_link': accountSettingsLink}
+                        )
+                    );
                 }
                 else {
                     this._super('');
-                }
-                return this._super();
+                } 
             },
 
             updateFieldValue: function() {
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 79f34a6..05d0156 100644
--- a/lms/static/js/student_profile/views/learner_profile_view.js
+++ b/lms/static/js/student_profile/views/learner_profile_view.js
@@ -1,11 +1,11 @@
 ;(function (define, undefined) {
     'use strict';
     define([
-        'gettext', 'jquery', 'underscore', 'backbone',
+        'gettext', 'jquery', 'underscore', 'backbone', 'edx-ui-toolkit/js/utils/html-utils',
         'common/js/components/views/tabbed_view',
         'js/student_profile/views/section_two_tab',
         'text!templates/student_profile/learner_profile.underscore'],
-        function (gettext, $, _, Backbone, TabbedView, SectionTwoTab, learnerProfileTemplate) {
+        function (gettext, $, _, Backbone, HtmlUtils, TabbedView, SectionTwoTab, learnerProfileTemplate) {
 
         var LearnerProfileView = Backbone.View.extend({
 
@@ -52,7 +52,7 @@
                     {view: this.sectionTwoView, title: gettext("About Me"), url: "about_me"}
                 ];
 
-                this.$el.html(this.template({
+                HtmlUtils.setHtml(this.$el, HtmlUtils.template(learnerProfileTemplate)({
                     username: self.options.accountSettingsModel.get('username'),
                     ownProfile: self.options.ownProfile,
                     showFullProfile: self.showFullProfile()
diff --git a/lms/static/js/views/fields.js b/lms/static/js/views/fields.js
index 0148725..a5d15ce 100644
--- a/lms/static/js/views/fields.js
+++ b/lms/static/js/views/fields.js
@@ -2,13 +2,14 @@
     'use strict';
     define([
         'gettext', 'jquery', 'underscore', 'backbone', 
+        'edx-ui-toolkit/js/utils/html-utils',
         'text!templates/fields/field_readonly.underscore',
         'text!templates/fields/field_dropdown.underscore',
         'text!templates/fields/field_link.underscore',
         'text!templates/fields/field_text.underscore',
         'text!templates/fields/field_textarea.underscore',
         'backbone-super'
-    ], function (gettext, $, _, Backbone,
+    ], function (gettext, $, _, Backbone, HtmlUtils,
                  field_readonly_template,
                  field_dropdown_template,
                  field_link_template,
@@ -30,12 +31,36 @@
             tagName: 'div',
 
             indicators: {
-                'canEdit': '<span class="icon fa fa-pencil message-can-edit" aria-hidden="true"></span><span class="sr">' + gettext("Editable") + '</span>', // jshint ignore:line
-                'error': '<span class="fa fa-exclamation-triangle message-error" aria-hidden="true"></span><span class="sr">' + gettext("Error") + '</span>', // jshint ignore:line
-                'validationError': '<span class="fa fa-exclamation-triangle message-validation-error" aria-hidden="true"></span><span class="sr">' + gettext("Validation Error") + '</span>', // jshint ignore:line
-                'inProgress': '<span class="fa fa-spinner fa-pulse message-in-progress" aria-hidden="true"></span><span class="sr">' + gettext("In Progress") + '</span>', // jshint ignore:line
-                'success': '<span class="fa fa-check message-success" aria-hidden="true"></span><span class="sr">' + gettext("Success") + '</span>', // jshint ignore:line
-                'plus': '<span class="fa fa-plus placeholder" aria-hidden="true"></span><span class="sr">' + gettext("Placeholder")+ '</span>' // jshint ignore:line
+                'canEdit': HtmlUtils.joinHtml(
+                    HtmlUtils.HTML('<span class="icon fa fa-pencil message-can-edit" aria-hidden="true"></span><span class="sr">'), // jshint ignore:line
+                    gettext("Editable"),
+                    HtmlUtils.HTML('</span>')
+                ),
+                'error': HtmlUtils.joinHtml(
+                    HtmlUtils.HTML('<span class="fa fa-exclamation-triangle message-error" aria-hidden="true"></span><span class="sr">'), // jshint ignore:line
+                    gettext("Error"),
+                    HtmlUtils.HTML('</span>')
+                ),
+                'validationError': HtmlUtils.joinHtml(
+                    HtmlUtils.HTML('<span class="fa fa-exclamation-triangle message-validation-error" aria-hidden="true"></span><span class="sr">'), // jshint ignore:line
+                    gettext("Validation Error"),
+                    HtmlUtils.HTML('</span>')
+                ),
+                'inProgress': HtmlUtils.joinHtml(
+                    HtmlUtils.HTML('<span class="fa fa-spinner fa-pulse message-in-progress" aria-hidden="true"></span><span class="sr">'), // jshint ignore:line
+                    gettext("In Progress"),
+                    HtmlUtils.HTML('</span>')
+                ),
+                'success': HtmlUtils.joinHtml(
+                    HtmlUtils.HTML('<span class="fa fa-check message-success" aria-hidden="true"></span><span class="sr">'), // jshint ignore:line
+                    gettext("Success"),
+                    HtmlUtils.HTML('</span>')
+                ),
+                'plus': HtmlUtils.joinHtml(
+                    HtmlUtils.HTML('<span class="fa fa-plus placeholder" aria-hidden="true"></span><span class="sr">'),
+                    gettext("Placeholder"),
+                    HtmlUtils.HTML('</span>')
+                )
             },
 
             messages: {
@@ -57,6 +82,7 @@
 
                 this.helpMessage = this.options.helpMessage || '';
                 this.showMessages = _.isUndefined(this.options.showMessages) ? true : this.options.showMessages;
+                this.enabled = _.isUndefined(this.options.enabled) ? true: this.options.enabled;
 
                 _.bindAll(this, 'modelValue', 'modelValueIsSet', 'showNotificationMessage','getNotificationMessage',
                     'getMessage', 'title', 'showHelpMessage', 'showInProgressMessage', 'showSuccessMessage',
@@ -73,14 +99,14 @@
             },
 
             title: function (text) {
-                return this.$('.u-field-title').html(text);
+                return this.$('.u-field-title').text(text);
             },
 
             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 HtmlUtils.joinHtml(this.indicators[message_status], this.messages[message_status]);
                 }
                 return this.indicators[message_status];
             },
@@ -90,16 +116,16 @@
                     message = this.helpMessage;
                 }
                 this.$('.u-field-message-notification').html('');
-                this.$('.u-field-message-help').html(message);
+                HtmlUtils.setHtml(this.$('.u-field-message-help'), message);
             },
 
             getNotificationMessage: function() {
-                return this.$('.u-field-message-notification').html();
+                return HtmlUtils.HTML(this.$('.u-field-message-notification').html());
             },
 
             showNotificationMessage: function(message) {
                 this.$('.u-field-message-help').html('');
-                this.$('.u-field-message-notification').html(message);
+                HtmlUtils.setHtml(this.$('.u-field-message-notification'), message);
             },
 
             showCanEditMessage: function(show) {
@@ -128,7 +154,8 @@
                 this.lastSuccessMessageContext = context;
 
                 setTimeout(function () {
-                    if ((context === view.lastSuccessMessageContext) && (view.getNotificationMessage() === successMessage)) {
+                    if ((context === view.lastSuccessMessageContext) &&
+                        (view.getNotificationMessage().toString() === successMessage.toString())) {
                         if (view.editable === 'toggle') {
                             view.showCanEditMessage(true);
                         } else {
@@ -142,10 +169,8 @@
                 if (xhr.status === 400) {
                     try {
                         var errors = JSON.parse(xhr.responseText),
-                            validationErrorMessage = _.escape(
-                                errors.field_errors[this.options.valueAttribute].user_message
-                            ),
-                            message = this.indicators.validationError + validationErrorMessage;
+                            validationErrorMessage = errors.field_errors[this.options.valueAttribute].user_message,
+                            message = HtmlUtils.joinHtml(this.indicators.validationError, validationErrorMessage);
                         this.showNotificationMessage(message);
                     } catch (error) {
                         this.showNotificationMessage(this.getMessage('error'));
@@ -271,7 +296,7 @@
             },
 
             render: function () {
-                this.$el.html(this.template({
+                HtmlUtils.setHtml(this.$el, HtmlUtils.template(this.fieldTemplate)({
                     id: this.options.valueAttribute,
                     title: this.options.title,
                     screenReaderTitle: this.options.screenReaderTitle || this.options.title,
@@ -287,7 +312,7 @@
             },
 
             updateValueInField: function () {
-                this.$('.u-field-value ').html(_.escape(this.modelValue()));
+                this.$('.u-field-value ').text(this.modelValue());
             }
         });
 
@@ -308,7 +333,7 @@
             },
 
             render: function () {
-                this.$el.html(this.template({
+                HtmlUtils.setHtml(this.$el, HtmlUtils.template(this.fieldTemplate)({
                     id: this.options.valueAttribute,
                     title: this.options.title,
                     value: this.modelValue(),
@@ -354,7 +379,7 @@
             },
 
             render: function () {
-                this.$el.html(this.template({
+                HtmlUtils.setHtml(this.$el, HtmlUtils.template(this.fieldTemplate)({
                     id: this.options.valueAttribute,
                     mode: this.mode,
                     editable: this.editable,
@@ -418,7 +443,7 @@
                     value = this.options.placeholderValue || '';
                 }
                 this.$('.u-field-value').attr('aria-label', this.options.title);
-                this.$('.u-field-value-readonly').html(_.escape(value));
+                this.$('.u-field-value-readonly').text(value);
 
                 if (this.mode === 'display') {
                     this.updateDisplayModeClass();
@@ -492,7 +517,7 @@
                 if (this.mode === 'display') {
                     value = value || this.options.placeholderValue;
                 }
-                this.$el.html(this.template({
+                HtmlUtils.setHtml(this.$el, HtmlUtils.template(this.fieldTemplate)({
                     id: this.options.valueAttribute,
                     screenReaderTitle: this.options.screenReaderTitle || this.options.title,
                     mode: this.mode,
@@ -587,7 +612,7 @@
             },
 
             render: function () {
-                this.$el.html(this.template({
+                HtmlUtils.setHtml(this.$el, HtmlUtils.template(this.fieldTemplate)({
                     id: this.options.valueAttribute,
                     title: this.options.title,
                     screenReaderTitle: this.options.screenReaderTitle || this.options.title,
diff --git a/openedx/core/djangoapps/user_api/accounts/serializers.py b/openedx/core/djangoapps/user_api/accounts/serializers.py
index fdee8b0..35ff9e2 100644
--- a/openedx/core/djangoapps/user_api/accounts/serializers.py
+++ b/openedx/core/djangoapps/user_api/accounts/serializers.py
@@ -85,7 +85,6 @@ class UserReadOnlySerializer(serializers.Serializer):
                 user,
                 self.context.get('request')
             ),
-            "time_zone": None,
             "language_proficiencies": LanguageProficiencySerializer(
                 profile.language_proficiencies.all(),
                 many=True
diff --git a/openedx/core/djangoapps/user_api/models.py b/openedx/core/djangoapps/user_api/models.py
index 6d893ef..b6d519d 100644
--- a/openedx/core/djangoapps/user_api/models.py
+++ b/openedx/core/djangoapps/user_api/models.py
@@ -8,6 +8,9 @@ from django.db.models.signals import post_delete, pre_save, post_save
 from django.dispatch import receiver
 from model_utils.models import TimeStampedModel
 
+from pytz import common_timezones
+from util.date_utils import get_formatted_time_zone
+
 from util.model_utils import get_changed_fields_dict, emit_setting_changed_event
 from xmodule_django.models import CourseKeyField
 
@@ -27,6 +30,10 @@ class UserPreference(models.Model):
     key = models.CharField(max_length=255, db_index=True, validators=[RegexValidator(KEY_REGEX)])
     value = models.TextField()
 
+    TIME_ZONE_CHOICES = [
+        (tz, get_formatted_time_zone(tz)) for tz in common_timezones
+    ]
+
     class Meta(object):
         unique_together = ("user", "key")
 
diff --git a/openedx/core/djangoapps/user_api/preferences/api.py b/openedx/core/djangoapps/user_api/preferences/api.py
index cd4d94b..4976a76 100644
--- a/openedx/core/djangoapps/user_api/preferences/api.py
+++ b/openedx/core/djangoapps/user_api/preferences/api.py
@@ -21,6 +21,8 @@ from ..helpers import intercept_errors
 from ..models import UserOrgTag, UserPreference
 from ..serializers import UserSerializer, RawUserPreferenceSerializer
 
+from pytz import common_timezones_set
+
 log = logging.getLogger(__name__)
 
 
@@ -392,6 +394,17 @@ def validate_user_preference_serializer(serializer, preference_key, preference_v
                 "user_message": user_message,
             }
         })
+    if preference_key == "time_zone" and preference_value not in common_timezones_set:
+        developer_message = ugettext_noop(u"Value '{preference_value}' not valid for preference '{preference_key}': Not in timezone set.")  # pylint: disable=line-too-long
+        user_message = ugettext_noop(u"Value '{preference_value}' is not valid for user preference '{preference_key}'.")
+        raise PreferenceValidationError({
+            preference_key: {
+                "developer_message": developer_message.format(
+                    preference_key=preference_key, preference_value=preference_value
+                ),
+                "user_message": user_message.format(preference_key=preference_key, preference_value=preference_value)
+            }
+        })
 
 
 def _create_preference_update_error(preference_key, preference_value, error):
diff --git a/openedx/core/djangoapps/user_api/preferences/tests/test_views.py b/openedx/core/djangoapps/user_api/preferences/tests/test_views.py
index fab722d..111fff5 100644
--- a/openedx/core/djangoapps/user_api/preferences/tests/test_views.py
+++ b/openedx/core/djangoapps/user_api/preferences/tests/test_views.py
@@ -90,11 +90,13 @@ class TestPreferencesAPI(UserAPITestCase):
         # Create some test preferences values.
         set_user_preference(self.user, "dict_pref", {"int_key": 10})
         set_user_preference(self.user, "string_pref", "value")
+        set_user_preference(self.user, "time_zone", "Asia/Tokyo")
 
         # Log in the client and do the GET.
         client = self.login_client(api_client, user)
         response = self.send_get(client)
-        self.assertEqual({"dict_pref": "{'int_key': 10}", "string_pref": "value"}, response.data)
+        self.assertEqual({"dict_pref": "{'int_key': 10}", "string_pref": "value", "time_zone": "Asia/Tokyo"},
+                         response.data)
 
     @ddt.data(
         ("client", "user"),
@@ -178,6 +180,7 @@ class TestPreferencesAPI(UserAPITestCase):
         set_user_preference(self.user, "dict_pref", {"int_key": 10})
         set_user_preference(self.user, "string_pref", "value")
         set_user_preference(self.user, "extra_pref", "extra_value")
+        set_user_preference(self.user, "time_zone", "Asia/Macau")
 
         # Send the patch request
         self.client.login(username=self.user.username, password=self.test_password)
@@ -187,6 +190,7 @@ class TestPreferencesAPI(UserAPITestCase):
                 "string_pref": "updated_value",
                 "new_pref": "new_value",
                 "extra_pref": None,
+                "time_zone": "Europe/London",
             },
             expected_status=204
         )
@@ -197,6 +201,7 @@ class TestPreferencesAPI(UserAPITestCase):
             "dict_pref": "{'int_key': 10}",
             "string_pref": "updated_value",
             "new_pref": "new_value",
+            "time_zone": "Europe/London",
         }
         self.assertEqual(expected_preferences, response.data)
 
@@ -208,6 +213,7 @@ class TestPreferencesAPI(UserAPITestCase):
         set_user_preference(self.user, "dict_pref", {"int_key": 10})
         set_user_preference(self.user, "string_pref", "value")
         set_user_preference(self.user, "extra_pref", "extra_value")
+        set_user_preference(self.user, "time_zone", "Pacific/Midway")
 
         # Send the patch request
         self.client.login(username=self.user.username, password=self.test_password)
@@ -218,6 +224,7 @@ class TestPreferencesAPI(UserAPITestCase):
                 TOO_LONG_PREFERENCE_KEY: "new_value",
                 "new_pref": "new_value",
                 u"empty_pref_ȻħȺɍłɇs": "",
+                "time_zone": "Asia/Africa",
             },
             expected_status=400
         )
@@ -238,6 +245,11 @@ class TestPreferencesAPI(UserAPITestCase):
                     "developer_message": u"Preference 'empty_pref_ȻħȺɍłɇs' cannot be set to an empty value.",
                     "user_message": u"Preference 'empty_pref_ȻħȺɍłɇs' cannot be set to an empty value.",
                 },
+                "time_zone": {
+                    "developer_message": u"Value 'Asia/Africa' not valid for preference 'time_zone': Not in "
+                                         u"timezone set.",
+                    "user_message": u"Value 'Asia/Africa' is not valid for user preference 'time_zone'."
+                },
             }
         )
 
@@ -247,6 +259,7 @@ class TestPreferencesAPI(UserAPITestCase):
             u"dict_pref": u"{'int_key': 10}",
             u"string_pref": u"value",
             u"extra_pref": u"extra_value",
+            u"time_zone": u"Pacific/Midway",
         }
         self.assertEqual(expected_preferences, response.data)
 
--
libgit2 0.26.0