diff --git a/common/djangoapps/student/cookies.py b/common/djangoapps/student/cookies.py index 454ed4e486..d204020ab9 100644 --- a/common/djangoapps/student/cookies.py +++ b/common/djangoapps/student/cookies.py @@ -17,7 +17,7 @@ from student.models import CourseEnrollment CREATE_LOGON_COOKIE = Signal(providing_args=['user', 'response']) -def _get_cookie_settings(request): +def standard_cookie_settings(request): """ Returns the common cookie settings (e.g. expiration time). """ if request.session.get_expire_at_browser_close(): @@ -73,7 +73,7 @@ def set_logged_in_cookies(request, response, user): HttpResponse """ - cookie_settings = _get_cookie_settings(request) + cookie_settings = standard_cookie_settings(request) # Backwards compatibility: set the cookie indicating that the user # is logged in. This is just a boolean value, so it's not very useful. @@ -96,7 +96,7 @@ def set_logged_in_cookies(request, response, user): def set_user_info_cookie(response, request): """ Sets the user info cookie on the response. """ - cookie_settings = _get_cookie_settings(request) + cookie_settings = standard_cookie_settings(request) # In production, TLS should be enabled so that this cookie is encrypted # when we send it. We also need to set "secure" to True so that the browser diff --git a/lms/djangoapps/courseware/tests/test_course_info.py b/lms/djangoapps/courseware/tests/test_course_info.py index 9de1a6e56b..0013f5ad02 100644 --- a/lms/djangoapps/courseware/tests/test_course_info.py +++ b/lms/djangoapps/courseware/tests/test_course_info.py @@ -367,7 +367,7 @@ class SelfPacedCourseInfoTestCase(LoginEnrollmentTestCase, SharedModuleStoreTest self.assertEqual(resp.status_code, 200) def test_num_queries_instructor_paced(self): - self.fetch_course_info_with_queries(self.instructor_paced_course, 21, 4) + self.fetch_course_info_with_queries(self.instructor_paced_course, 24, 4) def test_num_queries_self_paced(self): - self.fetch_course_info_with_queries(self.self_paced_course, 21, 4) + self.fetch_course_info_with_queries(self.self_paced_course, 24, 4) diff --git a/lms/djangoapps/courseware/tests/test_i18n.py b/lms/djangoapps/courseware/tests/test_i18n.py index 1d52ae06d7..d7d7fe6b8b 100644 --- a/lms/djangoapps/courseware/tests/test_i18n.py +++ b/lms/djangoapps/courseware/tests/test_i18n.py @@ -2,6 +2,7 @@ Tests i18n in courseware """ +import json import re from django.conf import settings @@ -14,7 +15,6 @@ from nose.plugins.attrib import attr from openedx.core.djangoapps.dark_lang.models import DarkLangConfig from openedx.core.djangoapps.lang_pref import LANGUAGE_KEY -from openedx.core.djangoapps.user_api.preferences.api import set_user_preference from student.models import UserProfile from student.tests.factories import UserFactory @@ -175,6 +175,15 @@ class I18nLangPrefTests(BaseI18nTestCase): UserProfile.objects.create(user=self.user) self.user_login() + def set_lang_preference(self, language): + """Sets the user's language preference, allowing the LangPref middleware to operate to set the preference cookie.""" + response = self.client.patch( + reverse('preferences_api', args=[self.user.username]), + json.dumps({LANGUAGE_KEY: language}), + content_type="application/merge-patch+json" + ) + self.assertEqual(response.status_code, 204) + def test_lang_preference(self): # Regression test; LOC-87 self.release_languages('ar, es-419') @@ -184,13 +193,13 @@ class I18nLangPrefTests(BaseI18nTestCase): self.assert_tag_has_attr(response.content, "html", "lang", self.site_lang) # Set user language preference - set_user_preference(self.user, LANGUAGE_KEY, 'ar') + self.set_lang_preference('ar') # and verify we now get an ar response response = self.client.get(self.url) self.assert_tag_has_attr(response.content, "html", "lang", 'ar') # Verify that switching language preference gives the right language - set_user_preference(self.user, LANGUAGE_KEY, 'es-419') + self.set_lang_preference('es-419') response = self.client.get(self.url) self.assert_tag_has_attr(response.content, "html", "lang", 'es-419') @@ -199,7 +208,7 @@ class I18nLangPrefTests(BaseI18nTestCase): self.release_languages('ar, es-419') # Set user language preference - set_user_preference(self.user, LANGUAGE_KEY, 'ar') + self.set_lang_preference('ar') # Verify preview-lang takes precedence self.client.post(self.preview_language_url, {'preview_lang': 'eo', 'set_language': 'set_language'}) response = self.client.get(self.url) diff --git a/lms/envs/devstack_docker.py b/lms/envs/devstack_docker.py index 4a9b5e00e9..58ecb1fff2 100644 --- a/lms/envs/devstack_docker.py +++ b/lms/envs/devstack_docker.py @@ -30,6 +30,7 @@ FEATURES.update({ 'ENABLE_COURSEWARE_SEARCH': False, 'ENABLE_COURSE_DISCOVERY': False, 'ENABLE_DASHBOARD_SEARCH': False, + 'SHOW_LANGUAGE_SELECTOR': True }) ENABLE_MKTG_SITE = os.environ.get('ENABLE_MARKETING_SITE', False) diff --git a/openedx/core/djangoapps/lang_pref/__init__.py b/openedx/core/djangoapps/lang_pref/__init__.py index 4182bb6978..a68c8a5252 100644 --- a/openedx/core/djangoapps/lang_pref/__init__.py +++ b/openedx/core/djangoapps/lang_pref/__init__.py @@ -4,3 +4,9 @@ Useful information for setting the language preference # this is the UserPreference key for the user's preferred language LANGUAGE_KEY = 'pref-lang' + +LANGUAGE_COOKIE = 'edx-language-preference' + +LANGUAGE_HEADER = 'HTTP_ACCEPT_LANGUAGE' + +COOKIE_DURATION = 14 * 24 * 60 * 60 # 14 days in seconds diff --git a/openedx/core/djangoapps/lang_pref/middleware.py b/openedx/core/djangoapps/lang_pref/middleware.py index e2352f0780..6bc1985434 100644 --- a/openedx/core/djangoapps/lang_pref/middleware.py +++ b/openedx/core/djangoapps/lang_pref/middleware.py @@ -2,12 +2,17 @@ Middleware for Language Preferences """ +from django.conf import settings from django.utils.translation import LANGUAGE_SESSION_KEY from django.utils.translation.trans_real import parse_accept_lang_header -from openedx.core.djangoapps.lang_pref import LANGUAGE_KEY -from openedx.core.djangoapps.lang_pref.api import released_languages -from openedx.core.djangoapps.user_api.preferences.api import get_user_preference, delete_user_preference +from openedx.core.djangoapps.lang_pref import ( + LANGUAGE_KEY, LANGUAGE_COOKIE, LANGUAGE_HEADER, COOKIE_DURATION +) +from openedx.core.djangoapps.user_api.preferences.api import ( + get_user_preference, delete_user_preference, set_user_preference +) +from openedx.core.djangoapps.user_api.errors import UserAPIInternalError, UserAPIRequestError class LanguagePreferenceMiddleware(object): @@ -21,17 +26,46 @@ class LanguagePreferenceMiddleware(object): def process_request(self, request): """ If a user's UserPreference contains a language preference, use the user's preference. + Save the current language preference cookie as the user's preferred language. """ - languages = released_languages() - system_released_languages = [seq[0] for seq in languages] + cookie_lang = request.COOKIES.get(LANGUAGE_COOKIE, None) + if cookie_lang: + if request.user.is_authenticated(): + set_user_preference(request.user, LANGUAGE_KEY, cookie_lang) + accept_header = request.META.get(LANGUAGE_HEADER, None) + if accept_header: + current_langs = parse_accept_lang_header(accept_header) + # Promote the cookie_lang over any language currently in the accept header + current_langs = [(lang, qvalue) for (lang, qvalue) in current_langs if lang != cookie_lang] + current_langs.insert(0, (cookie_lang, 1)) + accept_header = ",".join("{};q={}".format(lang, qvalue) for (lang, qvalue) in current_langs) + else: + accept_header = cookie_lang + request.META[LANGUAGE_HEADER] = accept_header + + def process_response(self, request, response): # If the user is logged in, check for their language preference - if request.user.is_authenticated(): + if getattr(request, 'user', None) and request.user.is_authenticated(): # Get the user's language preference - user_pref = get_user_preference(request.user, LANGUAGE_KEY) - # Set it to the LANGUAGE_SESSION_KEY (Django-specific session setting governing language pref) - if user_pref: - if user_pref in system_released_languages: - request.session[LANGUAGE_SESSION_KEY] = user_pref + try: + user_pref = get_user_preference(request.user, LANGUAGE_KEY) + except (UserAPIRequestError, UserAPIInternalError): + # If we can't find the user preferences, then don't modify the cookie + pass + else: + # Set it in the LANGUAGE_COOKIE + if user_pref: + response.set_cookie( + LANGUAGE_COOKIE, + value=user_pref, + domain=settings.SESSION_COOKIE_DOMAIN, + max_age=COOKIE_DURATION, + ) else: - delete_user_preference(request.user, LANGUAGE_KEY) + response.delete_cookie( + LANGUAGE_COOKIE, + domain=settings.SESSION_COOKIE_DOMAIN + ) + + return response diff --git a/openedx/core/djangoapps/lang_pref/tests/test_middleware.py b/openedx/core/djangoapps/lang_pref/tests/test_middleware.py index 2664073fd4..1b67b015a6 100644 --- a/openedx/core/djangoapps/lang_pref/tests/test_middleware.py +++ b/openedx/core/djangoapps/lang_pref/tests/test_middleware.py @@ -2,17 +2,21 @@ Tests for lang_pref middleware. """ +import itertools import mock import ddt +from django.conf import settings from django.test import TestCase from django.test.client import RequestFactory +from django.http import HttpResponse from django.contrib.sessions.middleware import SessionMiddleware from django.utils.translation import LANGUAGE_SESSION_KEY +from django.utils.translation.trans_real import parse_accept_lang_header -from openedx.core.djangoapps.lang_pref import LANGUAGE_KEY +from openedx.core.djangoapps.lang_pref import LANGUAGE_KEY, LANGUAGE_COOKIE, COOKIE_DURATION from openedx.core.djangoapps.lang_pref.middleware import LanguagePreferenceMiddleware -from openedx.core.djangoapps.user_api.preferences.api import set_user_preference, get_user_preference +from openedx.core.djangoapps.user_api.preferences.api import set_user_preference, get_user_preference, delete_user_preference from student.tests.factories import UserFactory from student.tests.factories import AnonymousUserFactory @@ -34,73 +38,117 @@ class TestUserPreferenceMiddleware(TestCase): self.request.META['HTTP_ACCEPT_LANGUAGE'] = 'ar;q=1.0' # pylint: disable=no-member self.session_middleware.process_request(self.request) - def test_no_language_set_in_session_or_prefs(self): - # nothing set in the session or the prefs - self.middleware.process_request(self.request) - self.assertNotIn(LANGUAGE_SESSION_KEY, self.request.session) # pylint: disable=no-member + def test_logout_shouldnt_remove_cookie(self): - @mock.patch( - 'openedx.core.djangoapps.lang_pref.middleware.released_languages', - mock.Mock(return_value=[('eo', 'esperanto')]) - ) - def test_language_in_user_prefs(self): - # language set in the user preferences and not the session - set_user_preference(self.user, LANGUAGE_KEY, 'eo') - self.middleware.process_request(self.request) - self.assertEquals(self.request.session[LANGUAGE_SESSION_KEY], 'eo') # pylint: disable=no-member - - @mock.patch( - 'openedx.core.djangoapps.lang_pref.middleware.released_languages', - mock.Mock(return_value=[('en', 'english'), ('eo', 'esperanto')]) - ) - def test_language_in_session(self): - # language set in both the user preferences and session, - # preference should get precedence. The session will hold the last value, - # which is probably the user's last preference. Look up the updated preference. - - # Dark lang middleware should run after this middleware, so it can - # set a session language as an override of the user's preference. - self.request.session[LANGUAGE_SESSION_KEY] = 'en' # pylint: disable=no-member - set_user_preference(self.user, LANGUAGE_KEY, 'eo') self.middleware.process_request(self.request) - self.assertEquals(self.request.session[LANGUAGE_SESSION_KEY], 'eo') # pylint: disable=no-member - - @mock.patch( - 'openedx.core.djangoapps.lang_pref.middleware.released_languages', - mock.Mock(return_value=[('eo', 'dummy Esperanto'), ('ar', 'arabic'), ('eu-es', 'euskara (Espainia)')]) - ) - @ddt.data('ar;q=1.0', 'eu;q=1.0', 'es-419;q=1.0') - def test_browser_language_in_session_for_unauthenticated_user(self, accept_language): - """ - test: browser language should not be set in user session for unauthenticated user. - """ - self.request.META['HTTP_ACCEPT_LANGUAGE'] = accept_language # pylint: disable=no-member self.request.user = self.anonymous_user - self.middleware.process_request(self.request) - self.assertNotIn(LANGUAGE_SESSION_KEY, self.request.session) # pylint: disable=no-member - @mock.patch( - 'openedx.core.djangoapps.lang_pref.middleware.released_languages', - mock.Mock(return_value=[('en', 'english')]) - ) - def test_browser_language_not_be_in_session(self): - """ - test: browser language should not be set in user session if it is not supported by system. - """ - self.request.user = self.anonymous_user - self.middleware.process_request(self.request) - self.assertNotEqual(self.request.session.get(LANGUAGE_SESSION_KEY), 'ar') # pylint: disable=no-member + response = mock.Mock(spec=HttpResponse) + self.middleware.process_response(self.request, response) - @mock.patch( - 'openedx.core.djangoapps.lang_pref.middleware.released_languages', - mock.Mock(return_value=[('en', 'english'), ('ar', 'arabic')]) - ) - def test_delete_user_lang_preference_not_supported_by_system(self): + response.delete_cookie.assert_not_called() + + @ddt.data(None, 'es', 'en') + def test_preference_setting_changes_cookie(self, lang_pref_out): """ - test: user preferred language has been removed from user preferences model if it is not supported by system - for authenticated users. + Test that the LANGUAGE_COOKIE is always set to the user's current language preferences + at the end of the request, with an expiry that's the same as the users current session cookie. """ - set_user_preference(self.user, LANGUAGE_KEY, 'eo') + if lang_pref_out: + set_user_preference(self.user, LANGUAGE_KEY, lang_pref_out) + else: + delete_user_preference(self.user, LANGUAGE_KEY) + + response = mock.Mock(spec=HttpResponse) + self.middleware.process_response(self.request, response) + + if lang_pref_out: + response.set_cookie.assert_called_with( + LANGUAGE_COOKIE, + value=lang_pref_out, + domain=settings.SESSION_COOKIE_DOMAIN, + max_age=COOKIE_DURATION, + ) + else: + response.delete_cookie.assert_called_with( + LANGUAGE_COOKIE, + domain=settings.SESSION_COOKIE_DOMAIN, + ) + + self.assertNotIn(LANGUAGE_SESSION_KEY, self.request.session) + + @ddt.data(*itertools.product( + (None, 'eo', 'es'), # LANGUAGE_COOKIE + (None, 'es', 'en'), # Language Preference In + )) + @ddt.unpack + @mock.patch('openedx.core.djangoapps.lang_pref.middleware.set_user_preference') + def test_preference_cookie_changes_setting(self, lang_cookie, lang_pref_in, mock_set_user_preference): + self.request.COOKIES[LANGUAGE_COOKIE] = lang_cookie + + if lang_pref_in: + set_user_preference(self.user, LANGUAGE_KEY, lang_pref_in) + else: + delete_user_preference(self.user, LANGUAGE_KEY) + self.middleware.process_request(self.request) - self.assertEqual(get_user_preference(self.request.user, LANGUAGE_KEY), None) + + if lang_cookie is None: + self.assertEqual(mock_set_user_preference.mock_calls, []) + else: + mock_set_user_preference.assert_called_with(self.user, LANGUAGE_KEY, lang_cookie) + + @ddt.data(*( + (logged_in, ) + test_def + for logged_in in (True, False) + for test_def in [ + # (LANGUAGE_COOKIE, LANGUAGE_SESSION_KEY, Accept-Language In, Accept-Language Out) + (None, None, None, None), + (None, 'eo', None, None), + (None, 'eo', 'en', 'en'), + (None, None, 'en', 'en'), + ('en', None, None, 'en'), + ('en', None, 'eo', 'en;q=1.0,eo'), + ('en', None, 'en', 'en'), + ('en', 'eo', 'en', 'en'), + ('en', 'eo', 'eo', 'en;q=1.0,eo') + ] + )) + @ddt.unpack + def test_preference_cookie_overrides_browser(self, logged_in, lang_cookie, lang_session, accept_lang_in, accept_lang_out): + if not logged_in: + self.request.user = self.anonymous_user + if lang_cookie: + self.request.COOKIES[LANGUAGE_COOKIE] = lang_cookie + if lang_session: + self.request.session[LANGUAGE_SESSION_KEY] = lang_session + if accept_lang_in: + self.request.META['HTTP_ACCEPT_LANGUAGE'] = accept_lang_in + else: + del self.request.META['HTTP_ACCEPT_LANGUAGE'] + + self.middleware.process_request(self.request) + + accept_lang_result = self.request.META.get('HTTP_ACCEPT_LANGUAGE') + if accept_lang_result: + accept_lang_result = parse_accept_lang_header(accept_lang_result) + + if accept_lang_out: + accept_lang_out = parse_accept_lang_header(accept_lang_out) + + if accept_lang_out and accept_lang_result: + self.assertItemsEqual(accept_lang_result, accept_lang_out) + else: + self.assertEqual(accept_lang_result, accept_lang_out) + + self.assertEquals(self.request.session.get(LANGUAGE_SESSION_KEY), lang_session) + + def test_process_response_no_user_noop(self): + del self.request.user + response = mock.Mock(spec=HttpResponse) + + result = self.middleware.process_response(self.request, response) + + self.assertIs(result, response) + self.assertEqual(response.mock_calls, []) diff --git a/openedx/core/djangoapps/lang_pref/views.py b/openedx/core/djangoapps/lang_pref/views.py index 0331ff2c8c..fb198876bb 100644 --- a/openedx/core/djangoapps/lang_pref/views.py +++ b/openedx/core/djangoapps/lang_pref/views.py @@ -9,7 +9,7 @@ from django.http import HttpResponse from django.views.decorators.csrf import ensure_csrf_cookie from django.utils.translation import LANGUAGE_SESSION_KEY -from openedx.core.djangoapps.lang_pref import LANGUAGE_KEY +from openedx.core.djangoapps.lang_pref import LANGUAGE_KEY, LANGUAGE_COOKIE, COOKIE_DURATION @ensure_csrf_cookie @@ -17,9 +17,16 @@ def update_session_language(request): """ Update the language session key. """ + response = HttpResponse(200) if request.method == 'PATCH': data = json.loads(request.body) language = data.get(LANGUAGE_KEY, settings.LANGUAGE_CODE) if request.session.get(LANGUAGE_SESSION_KEY, None) != language: request.session[LANGUAGE_SESSION_KEY] = unicode(language) - return HttpResponse(200) + response.set_cookie( + LANGUAGE_COOKIE, + language, + domain=settings.SESSION_COOKIE_DOMAIN, + max_age=COOKIE_DURATION + ) + return response