diff --git a/django/contrib/auth/__init__.py b/django/contrib/auth/__init__.py index 6aa900fbe4..6d99aa0c9b 100644 --- a/django/contrib/auth/__init__.py +++ b/django/contrib/auth/__init__.py @@ -7,7 +7,6 @@ from django.core.exceptions import ImproperlyConfigured, PermissionDenied from django.middleware.csrf import rotate_token from django.utils.crypto import constant_time_compare from django.utils.module_loading import import_string -from django.utils.translation import LANGUAGE_SESSION_KEY from .signals import user_logged_in, user_logged_out, user_login_failed @@ -143,15 +142,7 @@ def logout(request): if not getattr(user, 'is_authenticated', True): user = None user_logged_out.send(sender=user.__class__, request=request, user=user) - - # remember language choice saved to session - language = request.session.get(LANGUAGE_SESSION_KEY) - request.session.flush() - - if language is not None: - request.session[LANGUAGE_SESSION_KEY] = language - if hasattr(request, 'user'): from django.contrib.auth.models import AnonymousUser request.user = AnonymousUser() diff --git a/django/contrib/sessions/backends/base.py b/django/contrib/sessions/backends/base.py index 5c50f87e34..0e8ade3fbc 100644 --- a/django/contrib/sessions/backends/base.py +++ b/django/contrib/sessions/backends/base.py @@ -1,6 +1,7 @@ import base64 import logging import string +import warnings from datetime import datetime, timedelta from django.conf import settings @@ -10,7 +11,9 @@ from django.utils import timezone from django.utils.crypto import ( constant_time_compare, get_random_string, salted_hmac, ) +from django.utils.deprecation import RemovedInDjango40Warning from django.utils.module_loading import import_string +from django.utils.translation import LANGUAGE_SESSION_KEY # session_key should not be case sensitive because some backends can store it # on case insensitive file systems. @@ -51,6 +54,13 @@ class SessionBase: return key in self._session def __getitem__(self, key): + if key == LANGUAGE_SESSION_KEY: + warnings.warn( + 'The user language will no longer be stored in ' + 'request.session in Django 4.0. Read it from ' + 'request.COOKIES[settings.LANGUAGE_COOKIE_NAME] instead.', + RemovedInDjango40Warning, stacklevel=2, + ) return self._session[key] def __setitem__(self, key, value): diff --git a/django/utils/translation/trans_real.py b/django/utils/translation/trans_real.py index d6f1f7f14f..f4985fb3c1 100644 --- a/django/utils/translation/trans_real.py +++ b/django/utils/translation/trans_real.py @@ -15,7 +15,7 @@ from django.core.signals import setting_changed from django.dispatch import receiver from django.utils.safestring import SafeData, mark_safe -from . import LANGUAGE_SESSION_KEY, to_language, to_locale +from . import to_language, to_locale # Translations are cached in a dictionary for every language. # The active translations are stored by threadid to make them thread local. @@ -456,14 +456,9 @@ def get_language_from_request(request, check_path=False): if lang_code is not None: return lang_code - supported_lang_codes = get_languages() - - if hasattr(request, 'session'): - lang_code = request.session.get(LANGUAGE_SESSION_KEY) - if lang_code in supported_lang_codes and lang_code is not None and check_for_language(lang_code): - return lang_code - lang_code = request.COOKIES.get(settings.LANGUAGE_COOKIE_NAME) + if lang_code is not None and lang_code in get_languages() and check_for_language(lang_code): + return lang_code try: return get_supported_language_variant(lang_code) diff --git a/django/views/i18n.py b/django/views/i18n.py index f684da23dc..ce5691632c 100644 --- a/django/views/i18n.py +++ b/django/views/i18n.py @@ -47,6 +47,8 @@ def set_language(request): if next_trans != next: response = HttpResponseRedirect(next_trans) if hasattr(request, 'session'): + # Storing the language in the session is deprecated. + # (RemovedInDjango40Warning) request.session[LANGUAGE_SESSION_KEY] = lang_code response.set_cookie( settings.LANGUAGE_COOKIE_NAME, lang_code, diff --git a/docs/internals/deprecation.txt b/docs/internals/deprecation.txt index 27dafd7b96..02935c01a2 100644 --- a/docs/internals/deprecation.txt +++ b/docs/internals/deprecation.txt @@ -24,6 +24,9 @@ details on these changes. ``ugettext_noop()``, ``ungettext()``, and ``ungettext_lazy()`` will be removed. +* ``django.views.i18n.set_language()`` will no longer set the user language in + ``request.session`` (key ``django.utils.translation.LANGUAGE_SESSION_KEY``). + .. _deprecation-removed-in-3.1: 3.1 diff --git a/docs/ref/utils.txt b/docs/ref/utils.txt index cd7c7aca63..2becfea269 100644 --- a/docs/ref/utils.txt +++ b/docs/ref/utils.txt @@ -1106,3 +1106,8 @@ functions without the ``u``. Session key under which the active language for the current session is stored. + + .. deprecated:: 3.0 + + The language won't be stored in the session in Django 4.0. Use the + :setting:`LANGUAGE_COOKIE_NAME` cookie instead. diff --git a/docs/releases/3.0.txt b/docs/releases/3.0.txt index b2e6b479e7..f036969c92 100644 --- a/docs/releases/3.0.txt +++ b/docs/releases/3.0.txt @@ -302,6 +302,11 @@ Miscellaneous * ``ContentType.__str__()`` now includes the model's ``app_label`` to disambiguate model's with the same name in different apps. +* Because accessing the language in the session rather than in the cookie is + deprecated, ``LocaleMiddleware`` no longer looks for the user's language in + the session and :func:`django.contrib.auth.logout` no longer preserves the + session's language after logout. + .. _deprecated-features-3.0: Features deprecated in 3.0 @@ -332,6 +337,11 @@ Miscellaneous :func:`~django.utils.translation.ngettext`, and :func:`~django.utils.translation.ngettext_lazy`. +* To limit creation of sessions and hence favor some caching strategies, + :func:`django.views.i18n.set_language` will stop setting the user's language + in the session in Django 4.0. Since Django 2.1, the language is always stored + in the :setting:`LANGUAGE_COOKIE_NAME` cookie. + .. _removed-features-3.0: Features removed in 3.0 diff --git a/docs/topics/i18n/translation.txt b/docs/topics/i18n/translation.txt index 3b09e41617..00338100f9 100644 --- a/docs/topics/i18n/translation.txt +++ b/docs/topics/i18n/translation.txt @@ -1824,20 +1824,8 @@ You may want to set the active language for the current session explicitly. Perh a user's language preference is retrieved from another system, for example. You've already been introduced to :func:`django.utils.translation.activate()`. That applies to the current thread only. To persist the language for the entire -session, also modify :data:`~django.utils.translation.LANGUAGE_SESSION_KEY` -in the session:: - - from django.utils import translation - user_language = 'fr' - translation.activate(user_language) - request.session[translation.LANGUAGE_SESSION_KEY] = user_language - -You would typically want to use both: :func:`django.utils.translation.activate()` -will change the language for this thread, and modifying the session makes this -preference persist in future requests. - -If you are not using sessions, the language will persist in a cookie, whose name -is configured in :setting:`LANGUAGE_COOKIE_NAME`. For example:: +session in a cookie, set the :setting:`LANGUAGE_COOKIE_NAME` cookie on the +response:: from django.conf import settings from django.http import HttpResponse @@ -1847,6 +1835,14 @@ is configured in :setting:`LANGUAGE_COOKIE_NAME`. For example:: response = HttpResponse(...) response.set_cookie(settings.LANGUAGE_COOKIE_NAME, user_language) +You would typically want to use both: :func:`django.utils.translation.activate()` +changes the language for this thread, and setting the cookie makes this +preference persist in future requests. + +.. versionchanged:: 3.0 + + In older versions, you could set the language in the current session. + Using translations outside views and templates ---------------------------------------------- @@ -1980,9 +1976,6 @@ following this algorithm: root URLconf. See :ref:`url-internationalization` for more information about the language prefix and how to internationalize URL patterns. -* Failing that, it looks for the :data:`~django.utils.translation.LANGUAGE_SESSION_KEY` - key in the current user's session. - * Failing that, it looks for a cookie. The name of the cookie used is set by the :setting:`LANGUAGE_COOKIE_NAME` diff --git a/tests/auth_tests/test_views.py b/tests/auth_tests/test_views.py index 4949ce1bf4..f738a73e64 100644 --- a/tests/auth_tests/test_views.py +++ b/tests/auth_tests/test_views.py @@ -31,7 +31,6 @@ from django.test import Client, TestCase, override_settings from django.test.client import RedirectCycleError from django.urls import NoReverseMatch, reverse, reverse_lazy from django.utils.http import urlsafe_base64_encode -from django.utils.translation import LANGUAGE_SESSION_KEY from .client import PasswordResetConfirmClient from .models import CustomUser, UUIDUser @@ -1075,16 +1074,12 @@ class LogoutTest(AuthViewsTestCase): self.confirm_logged_out() def test_logout_preserve_language(self): - """Language stored in session is preserved after logout""" - # Create a new session with language - engine = import_module(settings.SESSION_ENGINE) - session = engine.SessionStore() - session[LANGUAGE_SESSION_KEY] = 'pl' - session.save() - self.client.cookies[settings.SESSION_COOKIE_NAME] = session.session_key - + """Language is preserved after logout.""" + self.login() + self.client.post('/setlang/', {'language': 'pl'}) + self.assertEqual(self.client.cookies[settings.LANGUAGE_COOKIE_NAME].value, 'pl') self.client.get('/logout/') - self.assertEqual(self.client.session[LANGUAGE_SESSION_KEY], 'pl') + self.assertEqual(self.client.cookies[settings.LANGUAGE_COOKIE_NAME].value, 'pl') @override_settings(LOGOUT_REDIRECT_URL='/custom/') def test_logout_redirect_url_setting(self): diff --git a/tests/auth_tests/urls.py b/tests/auth_tests/urls.py index d0b259935b..142a2b49c2 100644 --- a/tests/auth_tests/urls.py +++ b/tests/auth_tests/urls.py @@ -9,6 +9,7 @@ from django.shortcuts import render from django.template import RequestContext, Template from django.urls import path, re_path, reverse_lazy from django.views.decorators.cache import never_cache +from django.views.i18n import set_language class CustomRequestAuthenticationForm(AuthenticationForm): @@ -148,6 +149,7 @@ urlpatterns = auth_urlpatterns + [ path('permission_required_exception/', permission_required_exception), path('login_and_permission_required_exception/', login_and_permission_required_exception), + path('setlang/', set_language, name='set_language'), # This line is only required to render the password reset with is_admin=True path('admin/', admin.site.urls), ] diff --git a/tests/view_tests/tests/test_i18n.py b/tests/view_tests/tests/test_i18n.py index a3463a1bab..7b36181800 100644 --- a/tests/view_tests/tests/test_i18n.py +++ b/tests/view_tests/tests/test_i18n.py @@ -4,11 +4,12 @@ from os import path from django.conf import settings from django.test import ( - RequestFactory, SimpleTestCase, TestCase, modify_settings, + RequestFactory, SimpleTestCase, TestCase, ignore_warnings, modify_settings, override_settings, ) from django.test.selenium import SeleniumTestCase from django.urls import reverse +from django.utils.deprecation import RemovedInDjango40Warning from django.utils.translation import ( LANGUAGE_SESSION_KEY, get_language, override, ) @@ -36,7 +37,8 @@ class SetLanguageTests(TestCase): post_data = {'language': lang_code, 'next': '/'} response = self.client.post('/i18n/setlang/', post_data, HTTP_REFERER='/i_should_not_be_used/') self.assertRedirects(response, '/') - self.assertEqual(self.client.session[LANGUAGE_SESSION_KEY], lang_code) + with ignore_warnings(category=RemovedInDjango40Warning): + self.assertEqual(self.client.session[LANGUAGE_SESSION_KEY], lang_code) # The language is set in a cookie. language_cookie = self.client.cookies[settings.LANGUAGE_COOKIE_NAME] self.assertEqual(language_cookie.value, lang_code) @@ -53,7 +55,9 @@ class SetLanguageTests(TestCase): post_data = {'language': lang_code, 'next': '//unsafe/redirection/'} response = self.client.post('/i18n/setlang/', data=post_data) self.assertEqual(response.url, '/') - self.assertEqual(self.client.session[LANGUAGE_SESSION_KEY], lang_code) + self.assertEqual(self.client.cookies[settings.LANGUAGE_COOKIE_NAME].value, lang_code) + with ignore_warnings(category=RemovedInDjango40Warning): + self.assertEqual(self.client.session[LANGUAGE_SESSION_KEY], lang_code) def test_setlang_http_next(self): """ @@ -66,11 +70,15 @@ class SetLanguageTests(TestCase): # Insecure URL in POST data. response = self.client.post('/i18n/setlang/', data=post_data, secure=True) self.assertEqual(response.url, '/') - self.assertEqual(self.client.session[LANGUAGE_SESSION_KEY], lang_code) + self.assertEqual(self.client.cookies[settings.LANGUAGE_COOKIE_NAME].value, lang_code) + with ignore_warnings(category=RemovedInDjango40Warning): + self.assertEqual(self.client.session[LANGUAGE_SESSION_KEY], lang_code) # Insecure URL in HTTP referer. response = self.client.post('/i18n/setlang/', secure=True, HTTP_REFERER=non_https_next_url) self.assertEqual(response.url, '/') - self.assertEqual(self.client.session[LANGUAGE_SESSION_KEY], lang_code) + self.assertEqual(self.client.cookies[settings.LANGUAGE_COOKIE_NAME].value, lang_code) + with ignore_warnings(category=RemovedInDjango40Warning): + self.assertEqual(self.client.session[LANGUAGE_SESSION_KEY], lang_code) def test_setlang_redirect_to_referer(self): """ @@ -81,7 +89,9 @@ class SetLanguageTests(TestCase): post_data = {'language': lang_code} response = self.client.post('/i18n/setlang/', post_data, HTTP_REFERER='/i18n/') self.assertRedirects(response, '/i18n/', fetch_redirect_response=False) - self.assertEqual(self.client.session[LANGUAGE_SESSION_KEY], lang_code) + self.assertEqual(self.client.cookies[settings.LANGUAGE_COOKIE_NAME].value, lang_code) + with ignore_warnings(category=RemovedInDjango40Warning): + self.assertEqual(self.client.session[LANGUAGE_SESSION_KEY], lang_code) def test_setlang_default_redirect(self): """ @@ -92,7 +102,9 @@ class SetLanguageTests(TestCase): post_data = {'language': lang_code} response = self.client.post('/i18n/setlang/', post_data) self.assertRedirects(response, '/') - self.assertEqual(self.client.session[LANGUAGE_SESSION_KEY], lang_code) + self.assertEqual(self.client.cookies[settings.LANGUAGE_COOKIE_NAME].value, lang_code) + with ignore_warnings(category=RemovedInDjango40Warning): + self.assertEqual(self.client.session[LANGUAGE_SESSION_KEY], lang_code) def test_setlang_performs_redirect_for_ajax_if_explicitly_requested(self): """ @@ -102,7 +114,9 @@ class SetLanguageTests(TestCase): post_data = {'language': lang_code, 'next': '/'} response = self.client.post('/i18n/setlang/', post_data, HTTP_X_REQUESTED_WITH='XMLHttpRequest') self.assertRedirects(response, '/') - self.assertEqual(self.client.session[LANGUAGE_SESSION_KEY], lang_code) + self.assertEqual(self.client.cookies[settings.LANGUAGE_COOKIE_NAME].value, lang_code) + with ignore_warnings(category=RemovedInDjango40Warning): + self.assertEqual(self.client.session[LANGUAGE_SESSION_KEY], lang_code) def test_setlang_doesnt_perform_a_redirect_to_referer_for_ajax(self): """ @@ -114,7 +128,9 @@ class SetLanguageTests(TestCase): headers = {'HTTP_REFERER': '/', 'HTTP_X_REQUESTED_WITH': 'XMLHttpRequest'} response = self.client.post('/i18n/setlang/', post_data, **headers) self.assertEqual(response.status_code, 204) - self.assertEqual(self.client.session[LANGUAGE_SESSION_KEY], lang_code) + self.assertEqual(self.client.cookies[settings.LANGUAGE_COOKIE_NAME].value, lang_code) + with ignore_warnings(category=RemovedInDjango40Warning): + self.assertEqual(self.client.session[LANGUAGE_SESSION_KEY], lang_code) def test_setlang_doesnt_perform_a_default_redirect_for_ajax(self): """ @@ -124,7 +140,9 @@ class SetLanguageTests(TestCase): post_data = {'language': lang_code} response = self.client.post('/i18n/setlang/', post_data, HTTP_X_REQUESTED_WITH='XMLHttpRequest') self.assertEqual(response.status_code, 204) - self.assertEqual(self.client.session[LANGUAGE_SESSION_KEY], lang_code) + self.assertEqual(self.client.cookies[settings.LANGUAGE_COOKIE_NAME].value, lang_code) + with ignore_warnings(category=RemovedInDjango40Warning): + self.assertEqual(self.client.session[LANGUAGE_SESSION_KEY], lang_code) def test_setlang_unsafe_next_for_ajax(self): """ @@ -134,7 +152,16 @@ class SetLanguageTests(TestCase): post_data = {'language': lang_code, 'next': '//unsafe/redirection/'} response = self.client.post('/i18n/setlang/', post_data, HTTP_X_REQUESTED_WITH='XMLHttpRequest') self.assertEqual(response.url, '/') - self.assertEqual(self.client.session[LANGUAGE_SESSION_KEY], lang_code) + self.assertEqual(self.client.cookies[settings.LANGUAGE_COOKIE_NAME].value, lang_code) + + def test_session_langauge_deprecation(self): + msg = ( + 'The user language will no longer be stored in request.session ' + 'in Django 4.0. Read it from ' + 'request.COOKIES[settings.LANGUAGE_COOKIE_NAME] instead.' + ) + with self.assertRaisesMessage(RemovedInDjango40Warning, msg): + self.client.session[LANGUAGE_SESSION_KEY] def test_setlang_reversal(self): self.assertEqual(reverse('set_language'), '/i18n/setlang/') @@ -168,7 +195,9 @@ class SetLanguageTests(TestCase): encoded_url = '/test-setlang/%C3%A4/' # (%C3%A4 decodes to รค) response = self.client.post('/i18n/setlang/', {'language': lang_code}, HTTP_REFERER=encoded_url) self.assertRedirects(response, encoded_url, fetch_redirect_response=False) - self.assertEqual(self.client.session[LANGUAGE_SESSION_KEY], lang_code) + self.assertEqual(self.client.cookies[settings.LANGUAGE_COOKIE_NAME].value, lang_code) + with ignore_warnings(category=RemovedInDjango40Warning): + self.assertEqual(self.client.session[LANGUAGE_SESSION_KEY], lang_code) @modify_settings(MIDDLEWARE={ 'append': 'django.middleware.locale.LocaleMiddleware', @@ -178,7 +207,9 @@ class SetLanguageTests(TestCase): '/i18n/setlang/', data={'language': 'nl'}, follow=True, HTTP_REFERER='/en/translated/' ) - self.assertEqual(self.client.session[LANGUAGE_SESSION_KEY], 'nl') + self.assertEqual(self.client.cookies[settings.LANGUAGE_COOKIE_NAME].value, 'nl') + with ignore_warnings(category=RemovedInDjango40Warning): + self.assertEqual(self.client.session[LANGUAGE_SESSION_KEY], 'nl') self.assertRedirects(response, '/nl/vertaald/') # And reverse response = self.client.post(