locale detection and selection, refs #4994

Change-Id: I0f1e50d912ba7ac81b9ceb3ae95baaba3a12425b
Reviewed-on: https://gerrit.instructure.com/4661
Tested-by: Hudson <hudson@instructure.com>
Reviewed-by: Zach Wily <zach@instructure.com>
This commit is contained in:
Jon Jensen 2011-07-12 14:31:40 -06:00
parent b5035fe0d4
commit 5e66286c14
16 changed files with 398 additions and 13 deletions

View File

@ -23,7 +23,9 @@ class ApplicationController < ActionController::Base
attr_accessor :active_tab
before_filter :set_locale
include LocaleSelection
around_filter :set_locale
add_crumb "home", :root_path, :class => "home"
helper :all
filter_parameter_logging :password
@ -43,8 +45,15 @@ class ApplicationController < ActionController::Base
protected
def set_locale
# if params[:locale] is nil then I18n.default_locale will be used
I18n.locale = params[:locale] && I18n.available_locales.include?(params[:locale].to_sym) ? params[:locale] : nil
I18n.localizer = lambda {
infer_locale :context => @context,
:user => @current_user,
:root_account => @domain_root_account,
:accept_language => request.headers['Accept-Language']
}
yield if block_given?
ensure
I18n.localizer = nil
end
def init_body_classes_and_active_tab

View File

@ -19,6 +19,7 @@
# Methods added to this helper will be available to all templates in the application.
module ApplicationHelper
include TextHelper
include LocaleSelection
# Admins of the given context can see the User.name attribute,
# but everyone else sees the User.short_name attribute.

View File

@ -21,7 +21,7 @@ class Account < ActiveRecord::Base
attr_accessible :name, :turnitin_account_id,
:turnitin_shared_secret, :turnitin_comments, :turnitin_pledge,
:default_time_zone, :parent_account, :settings, :default_storage_quota,
:default_storage_quota_mb, :storage_quota, :ip_filters
:default_storage_quota_mb, :storage_quota, :ip_filters, :default_locale
include Workflow
belongs_to :parent_account, :class_name => 'Account'
@ -81,6 +81,13 @@ class Account < ActiveRecord::Base
scopes_custom_fields
validates_locale :default_locale, :allow_nil => true
def default_locale(recurse = false)
read_attribute(:default_locale) ||
(recurse && parent_account ? parent_account.default_locale(true) : nil)
end
cattr_accessor :account_settings_options
self.account_settings_options = {}

View File

@ -57,7 +57,8 @@ class Course < ActiveRecord::Base
:storage_quota_mb,
:restrict_enrollments_to_course_dates,
:grading_standard,
:grading_standard_enabled
:grading_standard_enabled,
:locale
serialize :tab_configuration
belongs_to :root_account, :class_name => 'Account'
@ -155,6 +156,7 @@ class Course < ActiveRecord::Base
after_save :update_account_associations_if_changed
before_validation :verify_unique_sis_source_id
validates_length_of :syllabus_body, :maximum => maximum_long_text_length, :allow_nil => true, :allow_blank => true
validates_locale :allow_nil => true
sanitize_field :syllabus_body, Instructure::SanitizeField::SANITIZE

View File

@ -19,7 +19,7 @@
class User < ActiveRecord::Base
include Context
attr_accessible :name, :short_name, :time_zone, :show_user_services, :gender, :visible_inbox_types, :avatar_image, :subscribe_to_emails
attr_accessible :name, :short_name, :time_zone, :show_user_services, :gender, :visible_inbox_types, :avatar_image, :subscribe_to_emails, :locale
attr_accessor :original_id
before_save :infer_defaults
@ -170,6 +170,7 @@ class User < ActiveRecord::Base
has_a_broadcast_policy
validates_length_of :name, :maximum => maximum_string_length, :allow_nil => true
validates_locale :locale, :browser_locale, :allow_nil => true
before_save :assign_uuid
before_save :update_avatar_image

View File

@ -69,6 +69,16 @@
</td>
<td><%= t(:megabytes_field, "%{text_field} megabytes", :text_field => f.text_field(:default_storage_quota_mb, :style => "width: 50px;", :title => t(:megabytes_tooltip, "megabytes"))) %></td>
</tr>
<% if available_locales.size > 1 %>
<tr>
<td><%= f.blabel :default_locale, :default_language, :en => "Default Language" %></td>
<td>
<% no_language = t(:no_language_preference, "Not set (defaults to %{language})", :language => available_locales[infer_locale(:context => @context.parent_account)]) %>
<%= f.select :default_locale, [[no_language, nil]] + available_locales.invert.sort, :selected => @context.default_locale %>
<p style="font-size: 0.9em;"><%= t(:default_language_description, "This will override any browser/OS language settings. Preferred languages can still be set at the course/user level.") %></p>
</td>
</tr>
<% end %>
<% if @account.root_account_id == nil %>
<tr>
<td><%= f.blabel :default_time_zone, :en => "Default Time Zone" %></td>

View File

@ -233,6 +233,20 @@ Hashtags should consist of letters, numbers, dashes and underscores (no spaces).
<%= @context.restrict_enrollments_to_course_dates ? t('date_restricted', "Users can only access the course between these dates") : t('not_date_restricted', "These dates will not affect course availability") %>
</div>
</td>
<% if available_locales.size > 1 %>
</tr><tr>
<td style="width: 10%;"><%= f.blabel :locale, :language, :en => "Language" %></td>
<td>
<span class="course_form">
<% no_language = t(:no_language_preference, "Not set (user-configurable, defaults to %{language})", :language => available_locales[infer_locale(:context => @context.account)]) %>
<%= f.select :locale, [[no_language, nil]] + available_locales.invert.sort, :selected => @context.locale %>
<div style="font-size: 0.8em;">
<%= t('language_overrides_preferences', "This will override any user/system language preferences. This is only recommended for foreign language courses") %>
</div>
</span>
<span class="course_info locale"><%= @context.locale ? available_locales[@context.locale] : no_language %></span>
</td>
<% end %>
</tr><tr>
<td style="width: 10%;"><%= f.blabel :storage_quota_mb, :storage_quota, :en => "File Storage" %></td>
<td>

View File

@ -49,10 +49,22 @@
</td>
</tr>
<% end %>
<% if available_locales.size > 1 %>
<tr>
<td><%= f.blabel :locale, :en => "Language" %></td>
<td>
<% no_language = t(:no_language_preference, "System Default (%{language})", :language => available_locales[infer_locale(:root_account => @domain_root_account, :accept_language => request.headers['Accept-Language'])]) %>
<span class="locale display_data"><%= @user.locale ? available_locales[@user.locale] : no_language %></span>
<span class="edit_data"><%= f.select :locale, [[no_language, nil]] + available_locales.invert.sort, :selected => @user.locale %>
<span style="font-size: 0.8em; color: #888;"><br/><%= t('hints.language', "This will override any browser or account settings.") %></span>
</span>
</td>
</tr>
<% end %>
<tr>
<td><%= f.blabel :time_zone, :en => "Time Zone" %></td>
<td>
<span class="time_zone display_data"><%= @user.time_zone || "None" %></span>
<span class="time_zone display_data"><%= @user.time_zone || t(:none, "None") %></span>
<span class="edit_data"><%= f.time_zone_select :time_zone, ActiveSupport::TimeZone.us_zones, :model => I18nTimeZone, :default => ((@domain_root_account && @domain_root_account.default_time_zone) || "Mountain Time (US & Canada)") %><%# that default time zone is a value, not text %></span>
</td>
</tr>

View File

@ -73,6 +73,8 @@ end
I18n.class_eval do
class << self
attr_writer :localizer
include ::ActionView::Helpers::TextHelper
# if one of the interpolated values is a SafeBuffer (e.g. the result of a
# link_to call) or the string itself is, we don't want anything to get
@ -96,6 +98,11 @@ I18n.class_eval do
alias_method_chain :localize, :whitespace_removal
def translate_with_default_and_count_magic(key, *args)
if @localizer
self.locale = @localizer.call
@localizer = nil
end
default = args.shift if args.first.is_a?(String) || args.size > 1
options = args.shift || {}
options[:default] ||= if options[:count]
@ -178,6 +185,21 @@ ActiveRecord::Base.class_eval do
I18n.translate(key, default, options)
end
alias :t :translate
def validates_locale(*args)
options = args.last.is_a?(Hash) ? args.pop : {}
args << :locale if args.empty?
if options[:allow_nil] && !options[:allow_empty]
before_validation do |record|
args.each do |field|
record.write_attribute(field, nil) if record.read_attribute(field) == ''
end
end
end
args.each do |field|
validates_inclusion_of field, options.merge(:in => I18n.available_locales.map(&:to_s))
end
end
end
end

View File

@ -0,0 +1,6 @@
en:
locales:
en: English
#es:
# locales:
# es: Español

View File

@ -0,0 +1,15 @@
class LocalePrefs < ActiveRecord::Migration
def self.up
add_column :users, :locale, :string
add_column :users, :browser_locale, :string
add_column :courses, :locale, :string
add_column :accounts, :default_locale, :string
end
def self.down
remove_column :users, :locale
remove_column :users, :browser_locale
remove_column :courses, :locale
remove_column :accounts, :default_locale
end
end

76
lib/locale_selection.rb Normal file
View File

@ -0,0 +1,76 @@
module LocaleSelection
def infer_locale(options = {})
context = options[:context]
user = options[:user]
root_account = options[:root_account]
accept_language = options[:accept_language]
if context && context.is_a?(Course) && context.locale
context.locale
elsif user && user.locale
user.locale
elsif context && context.is_a?(Course) && context.account && (account_locale = context.account.default_locale(true))
account_locale
elsif context && context.is_a?(Account) && (account_locale = context.default_locale(true))
account_locale
elsif root_account && root_account.default_locale
root_account.default_locale
elsif accept_language
locale = infer_browser_locale(accept_language, I18n.available_locales)
user.update_attribute(:browser_locale, locale) if user && locale && user.browser_locale != locale
locale
end || I18n.default_locale.to_s
end
QUALITY_VALUE = /;q=([01]\.(\d{0,3})?)/
LANGUAGE_RANGE = /([a-zA-Z]{1,8}(-[a-zA-Z]{1,8})*|\*)(#{QUALITY_VALUE})?/
SEPARATOR = /\s*,\s*/
ACCEPT_LANGUAGE = /\A#{LANGUAGE_RANGE}(#{SEPARATOR}#{LANGUAGE_RANGE})*\z/
def infer_browser_locale(accept_language, supported_locales)
return nil unless accept_language =~ ACCEPT_LANGUAGE
supported_locales = supported_locales.map(&:to_s)
ranges = accept_language.downcase.split(SEPARATOR).map{ |range|
quality = (range =~ QUALITY_VALUE) ? $1.to_f : 1
[range.sub(/\s*;.*/, ''), quality]
}
ranges = ranges.sort_by{ |r,| r == '*' ? 1 : -r.count('-') }
# we want the longest ranges first (and * last of all), since the "quality
# factor assigned to a [language] ... is the quality value of the longest
# language-range ... that matches", e.g.
# given that i accept 'en, es;q=0.9, en-US;q=0.8'
# and canvas is localized in 'en-US' and 'es'
# then i should get 'es' (en and en-US ranges both match en-US, and
# en-US range is a longer match, so it loses)
best_locales = supported_locales.inject([]) { |ary, locale|
if best_range = ranges.detect { |r, q| r + '-' == (locale.downcase + '-')[0..r.size] || r == '*' }
ary << [locale, best_range.last] unless best_range.last == 0
end
ary
}.sort_by{ |l, q| [-q, l.count('-'), l]}
# wrt the sorting here, rfc2616 doesn't specify which tag is preferable
# if there is a quality tie (due to prefix matching or otherwise).
# technically they are equally acceptable, though we'll just always go
# with the shorter one (and then alphabetical). this seems reasonable for
# scenarios like the following:
# given that i accept 'en'
# and canvas is localized in 'en-US', 'en-GB-oy' and 'en-CA-eh'
# then i should get 'en-US'
best_locales.first && best_locales.first.first
end
# gives you a hash of localized locales, e.g. {:en => "English", :es => "Español" }
# if the locale name is not yet translated, it won't be included (even if
# there are other translations for that locale)
def available_locales
I18n.available_locales.inject({}) do |hash, locale|
name = I18n.send(:t, "locales", :locale => locale)[locale]
hash[locale.to_s] = name if name
hash
end
end
end

View File

@ -268,7 +268,6 @@ I18n.scoped('course_details', function(I18n) {
$(".course_form_more_options").hide();
},
success: function(data) {
$(this).loadingImage('remove');
var course = data.course;
course.start_at = $.parseFromISO(course.start_at).datetime_formatted;
course.conclude_at = $.parseFromISO(course.conclude_at).datetime_formatted;
@ -276,6 +275,12 @@ I18n.scoped('course_details', function(I18n) {
course.indexed = course.indexed ? I18n.t('indexed_course', "Included in public course index") : "";
course.grading_scheme_set = course.grading_standard_title || (course.grading_standard_id ? I18n.t('grading_standard_set', "Currently Set") : I18n.t('grading_standard_unset', "Not Set"));
course.restrict_dates = course.restrict_enrollments_to_course_dates ? I18n.t('course_dates_enforced', "Users can only access the course between these dates") : I18n.t('course_dates_unenforced', "These dates will not affect course availability");
course.locale = $("#course_locale option[value='" + (course.locale || '') + "']").text();
if (course.locale != $course_form.find('.locale').text()) {
location.reload();
return;
}
$(this).loadingImage('remove');
$("#course_form .public_options").showIf(course.is_public);
$("#course_form .self_enrollment_message").css('display', course.self_enrollment ? '' : 'none');
$("#course_form").fillTemplateData({data: course});

View File

@ -74,6 +74,16 @@ $(function() {
},
success: function(data) {
var user = data.user;
var templateData = {
short_name: user.short_name,
full_name: user.name,
time_zone: user.time_zone,
locale: $("#user_locale option[value='" + user.locale + "']").text()
};
if (templateData.locale != $update_profile_form.find('.locale').text()) {
location.reload();
return;
}
$update_profile_form.loadingImage('remove');
if ($default_email_id.length > 0) {
var default_email = $default_email_id.find('option:selected').text();
@ -81,11 +91,6 @@ $(function() {
}
$('.channel').removeClass('default');
$("#channel_" + user.communication_channel.id).addClass('default');
var templateData = {
short_name: user.short_name,
full_name: user.name,
time_zone: user.time_zone
};
$update_profile_form.fillTemplateData({
data: templateData
}).find(".cancel_button").click();

View File

@ -0,0 +1,47 @@
#
# Copyright (C) 2011 Instructure, Inc.
#
# This file is part of Canvas.
#
# Canvas is free software: you can redistribute it and/or modify it under
# the terms of the GNU Affero General Public License as published by the Free
# Software Foundation, version 3 of the License.
#
# Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along
# with this program. If not, see <http://www.gnu.org/licenses/>.
#
require File.expand_path(File.dirname(__FILE__) + '/../spec_helper')
describe "locale_selection" do
before do
I18n.stub!(:available_locales).and_return([:en, :es, :fr])
end
after do
I18n.locale = nil
end
it "should set the locale when authenticated" do
course_with_teacher(:active_all => true, :user => user_with_pseudonym)
user_session(@user, @pseudonym)
@user.update_attribute :locale, 'es'
get dashboard_url
response.should be_success
I18n.locale.should eql(:es)
end
it "should set the locale when not authenticated" do
account = Account.default
account.update_attribute :default_locale, 'fr'
get login_url
response.should be_success
I18n.locale.should eql(:fr)
end
end

View File

@ -0,0 +1,153 @@
#
# Copyright (C) 2011 Instructure, Inc.
#
# This file is part of Canvas.
#
# Canvas is free software: you can redistribute it and/or modify it under
# the terms of the GNU Affero General Public License as published by the Free
# Software Foundation, version 3 of the License.
#
# Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along
# with this program. If not, see <http://www.gnu.org/licenses/>.
#
require File.expand_path(File.dirname(__FILE__) + '/../spec_helper')
describe LocaleSelection do
class TestClassForMixins
extend LocaleSelection
end
def ls
TestClassForMixins
end
context 'accept-language' do
it "should ignore malformed accept-language headers" do
ls.infer_browser_locale("en not valid", ['en']).should be_nil
end
it "should match valid locale ranges" do
ls.infer_browser_locale("en", ['en']).should eql('en')
end
it "should not match invalid locale ranges" do
ls.infer_browser_locale("it", ['en']).should be_nil
end
it "should do case-insensitive matching" do
ls.infer_browser_locale("en-us", ['en-US']).should eql('en-US')
end
# see rfc2616 ... en means any en(-.*)? is acceptable
it "should do range prefix-matching" do
ls.infer_browser_locale("en", ['en-US']).should eql('en-US')
end
# while tag prefix-matching might be desirable (sometimes), it should not
# be done automatically on the server-side (though the user-agent can do
# it). from the rfc:
# [U]sers might [incorrectly] assume that on selecting "en-gb", they
# will be served any kind of English document if British English is not
# available. A user agent might suggest in such a case to add "en" to
# get the best matching behavior.
it "should not do tag prefix-matching" do
ls.infer_browser_locale("en-US", ['en']).should be_nil
end
it "should assign quality values based on the best match" do
ls.infer_browser_locale("en-US, es;q=0.9, en;q=0.8", ['en-US', 'es']).should eql('en-US')
# no tag prefix-matching
ls.infer_browser_locale("en-US, es;q=0.9, en;q=0.8", ['en', 'es']).should eql('es')
# order doesn't matter
ls.infer_browser_locale("es;q=0.9, en", ['en', 'es']).should eql('en')
# although the en range matches the en-US tag, the en-US range is
# a better (read: longer) match. so the es tag ends up with a higher
# quality value than en-US tag
ls.infer_browser_locale("en, es;q=0.9, en-US;q=0.8", ['en-US', 'es']).should eql('es')
end
it "should understand wildcards" do
ls.infer_browser_locale("*, pt;q=0.8", ['ru', 'pt']).should eql('ru')
ls.infer_browser_locale("*, pt;q=0.8, ru;q=0.7", ['ru', 'pt']).should eql('pt')
# the pt range is explicitly rejected, so we don't get a locale
ls.infer_browser_locale("pt-BR, *;q=0.9, pt;q=0", ['pt']).should be_nil
# no pt variants supported, so we get the first alternative
ls.infer_browser_locale("pt-BR, pt;q=0.9, *;q=0.8", ['es', 'fr']).should eql('es')
end
end
context "locale matching" do
before do
I18n.stub!(:available_locales).and_return([:en, :it, :es, :fr, :de, :pt])
@root_account = Account.create
@account = Account.create(:parent_account => @root_account)
user
course
@course.account = @account
@course.save
end
it "should use the default locale if there is no other context" do
ls.infer_locale.should eql('en')
ls.infer_locale(:root_account => @root_account).should eql('en')
ls.infer_locale(:root_account => @root_account, :user => @user).should eql('en')
ls.infer_locale(:root_account => @root_account, :user => @user, :context => @course).should eql('en')
end
it "should infer the locale from the accept_language" do
ls.infer_locale(:accept_language => "it", :root_account => @root_account).should eql('it')
@user.browser_locale.should be_nil
ls.infer_locale(:accept_language => "it", :root_account => @root_account, :user => @user).should eql('it')
@user.browser_locale.should eql('it')
ls.infer_locale(:accept_language => "it", :root_account => @root_account, :user => @user, :context => @account).should eql('it')
ls.infer_locale(:accept_language => "it", :root_account => @root_account, :user => @user, :context => @course).should eql('it')
end
it "should infer the locale from the root account" do
@root_account.update_attribute(:default_locale, 'es')
ls.infer_locale(:accept_language => "it", :root_account => @root_account, :user => @user).should eql('es')
ls.infer_locale(:accept_language => "it", :root_account => @root_account, :user => @user, :context => @account).should eql('es')
ls.infer_locale(:accept_language => "it", :root_account => @root_account, :user => @user, :context => @course).should eql('es')
end
it "should infer the locale from the account" do
@root_account.update_attribute(:default_locale, 'es')
@account.update_attribute(:default_locale, 'fr')
ls.infer_locale(:accept_language => "it", :root_account => @root_account, :user => @user).should eql('es')
ls.infer_locale(:accept_language => "it", :root_account => @root_account, :user => @user, :context => @account).should eql('fr')
ls.infer_locale(:accept_language => "it", :root_account => @root_account, :user => @user, :context => @course).should eql('fr')
end
it "should infer the locale from the user" do
@root_account.update_attribute(:default_locale, 'es')
@account.update_attribute(:default_locale, 'fr')
@user.update_attribute(:locale, 'de')
ls.infer_locale(:accept_language => "it", :root_account => @root_account, :user => @user).should eql('de')
ls.infer_locale(:accept_language => "it", :root_account => @root_account, :user => @user, :context => @account).should eql('de')
ls.infer_locale(:accept_language => "it", :root_account => @root_account, :user => @user, :context => @course).should eql('de')
end
it "should infer the locale from the course" do
@root_account.update_attribute(:default_locale, 'es')
@account.update_attribute(:default_locale, 'fr')
@user.update_attribute(:locale, 'de')
@course.update_attribute(:locale, 'pt')
ls.infer_locale(:accept_language => "it", :root_account => @root_account, :user => @user).should eql('de')
ls.infer_locale(:accept_language => "it", :root_account => @root_account, :user => @user, :context => @account).should eql('de')
ls.infer_locale(:accept_language => "it", :root_account => @root_account, :user => @user, :context => @course).should eql('pt')
end
end
end