Merge pull request #12667 from edx/kkim/tz_pref_AS
Time Zone in Account Settings
This commit is contained in:
2
AUTHORS
2
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>
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -165,7 +165,8 @@ class AccountSettingsPageTest(AccountSettingsTestMixin, WebAppTest):
|
||||
'Email Address',
|
||||
'Password',
|
||||
'Language',
|
||||
'Country or Region'
|
||||
'Country or Region',
|
||||
'Time Zone',
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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):
|
||||
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)
|
||||
|
||||
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)
|
||||
|
||||
for attribute in self.FIELDS:
|
||||
self.assertIn(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):
|
||||
"""
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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++) {
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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.'));
|
||||
}
|
||||
}),
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user