From f8aa5cc8db8e426adbf26d17b835eab58bf55eea Mon Sep 17 00:00:00 2001 From: gpetrak Date: Fri, 17 Apr 2026 12:42:07 +0300 Subject: [PATCH 1/6] adding language field to the profile / users admin pages --- geonode/people/admin.py | 1 + geonode/people/forms/__init__.py | 29 +++++++++++++++++------------ 2 files changed, 18 insertions(+), 12 deletions(-) diff --git a/geonode/people/admin.py b/geonode/people/admin.py index ef142c7e411..bdf1e427df0 100644 --- a/geonode/people/admin.py +++ b/geonode/people/admin.py @@ -68,6 +68,7 @@ class ProfileAdmin(admin.ModelAdmin): "area", "zipcode", "country", + "language", "keywords", ) }, diff --git a/geonode/people/forms/__init__.py b/geonode/people/forms/__init__.py index cdb51f67823..a2310ab9af7 100644 --- a/geonode/people/forms/__init__.py +++ b/geonode/people/forms/__init__.py @@ -51,17 +51,22 @@ class ProfileForm(forms.ModelForm): class Meta: model = get_user_model() - exclude = ( - "user", - "password", - "last_login", - "groups", - "user_permissions", - "username", - "is_staff", - "is_superuser", - "is_active", - "date_joined", + # Explicitly listing fields instead of exclude them + fields = ( + "first_name", + "last_name", + "email", + "organization", + "profile", + "position", + "voice", + "fax", + "delivery", + "city", + "area", + "zipcode", + "country", "language", - "extra_data", + "keywords", + "timezone", ) From e970bc622fbcab5f666e8e9d5a43676200d8d0fa Mon Sep 17 00:00:00 2001 From: gpetrak Date: Mon, 20 Apr 2026 11:59:57 +0300 Subject: [PATCH 2/6] create the language-related custom middleware and view --- geonode/base/middleware.py | 25 +++++++++++++++++++++++++ geonode/people/views.py | 18 +++++++++++++++++- geonode/settings.py | 1 + geonode/urls.py | 3 ++- 4 files changed, 45 insertions(+), 2 deletions(-) diff --git a/geonode/base/middleware.py b/geonode/base/middleware.py index e1ac3fdaee8..730441f2643 100644 --- a/geonode/base/middleware.py +++ b/geonode/base/middleware.py @@ -21,6 +21,9 @@ # Geonode functionality from django.shortcuts import render +from django.utils import translation +from django.utils.deprecation import MiddlewareMixin + from geonode.base.utils import configuration_session_cache @@ -95,3 +98,25 @@ def process_view(self, request, view_func, view_args, view_kwargs): # check if the request is not against whitelisted views (check by URL names) if request.resolver_match.url_name not in self.WHITELISTED_URL_NAMES: return render(request, "base/maintenance.html", status=503) + + +SESSION_LANG_KEY = "language_override" + + +class ProfileLanguageMiddleware(MiddlewareMixin): + def process_request(self, request): + if not request.user.is_authenticated: + return None + + lang = request.session.get(SESSION_LANG_KEY) + + if not lang: + lang = getattr(request.user, "language", None) + if lang: + request.session[SESSION_LANG_KEY] = lang + + if lang: + translation.activate(lang) + request.LANGUAGE_CODE = lang + + return None diff --git a/geonode/people/views.py b/geonode/people/views.py index bf9e8f4cf8f..25207c7e57f 100644 --- a/geonode/people/views.py +++ b/geonode/people/views.py @@ -22,12 +22,14 @@ from django.contrib import messages from django.shortcuts import render, redirect, get_object_or_404 from django.urls import reverse -from django.utils.translation import gettext_lazy as _ +from django.utils.translation import check_for_language, gettext_lazy as _ from django.contrib.sites.models import Site from django.conf import settings from django.http import HttpResponseForbidden from django.db.models import Q from django.views import View +from django.views.decorators.http import require_POST +from django.views.i18n import set_language as django_set_language from geonode.tasks.tasks import send_email from geonode.people.forms import ProfileForm @@ -167,3 +169,17 @@ def get_queryset(self): ) return qs + + +SESSION_LANG_KEY = "language_override" + + +@require_POST +def set_session_language(request): + lang_code = request.POST.get("language") # LANGUAGE_QUERY_PARAMETER is "language" + + if lang_code and check_for_language(lang_code): + request.session[SESSION_LANG_KEY] = lang_code + + # Delegate all redirect/cookie logic to Django + return django_set_language(request) diff --git a/geonode/settings.py b/geonode/settings.py index 62d8750d83a..d649d68d236 100644 --- a/geonode/settings.py +++ b/geonode/settings.py @@ -851,6 +851,7 @@ "django_user_agents.middleware.UserAgentMiddleware", "geonode.base.middleware.MaintenanceMiddleware", "geonode.base.middleware.ReadOnlyMiddleware", # a Middleware enabling Read Only mode of Geonode + "geonode.base.middleware.ProfileLanguageMiddleware", ) MESSAGE_STORAGE = "django.contrib.messages.storage.cookie.CookieStorage" diff --git a/geonode/urls.py b/geonode/urls.py index a9a73f15605..34a78c5f1d1 100644 --- a/geonode/urls.py +++ b/geonode/urls.py @@ -42,7 +42,7 @@ from geonode import geoserver from geonode.utils import check_ogc_backend from geonode.base import register_url_event -from .people.views import CustomSignupView, CustomLoginView +from .people.views import CustomSignupView, CustomLoginView, set_session_language from oauth2_provider.urls import app_name as oauth2_app_name, base_urlpatterns, oidc_urlpatterns admin.autodiscover() @@ -141,6 +141,7 @@ ImporterViewSet.as_view({"post": "create"}), name="importer_upload", ), + path("i18n/setlang/", set_session_language, name="set_language"), ] # django-select2 Widgets From 3ddfae9c840cebffc2a20b35a052ebdb4bafb767 Mon Sep 17 00:00:00 2001 From: gpetrak Date: Mon, 20 Apr 2026 13:34:33 +0300 Subject: [PATCH 3/6] adding a tooltip in the language field --- geonode/people/templates/people/profile_edit.html | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/geonode/people/templates/people/profile_edit.html b/geonode/people/templates/people/profile_edit.html index 84b772f8027..7592f0f50a4 100644 --- a/geonode/people/templates/people/profile_edit.html +++ b/geonode/people/templates/people/profile_edit.html @@ -29,7 +29,18 @@

{% trans "Edit Profile for" %} {{ profile.username }}

- +
{% render_field field class="form-control" %}
From 04d14bed2c6abca576e96db0138a21a2295b83e5 Mon Sep 17 00:00:00 2001 From: gpetrak Date: Mon, 20 Apr 2026 13:53:49 +0300 Subject: [PATCH 4/6] Remove the duplicate definition of SESSION_LANG_KEY --- geonode/people/views.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/geonode/people/views.py b/geonode/people/views.py index 25207c7e57f..d9350532fcd 100644 --- a/geonode/people/views.py +++ b/geonode/people/views.py @@ -37,6 +37,7 @@ from geonode.base.auth import get_or_create_token from geonode.people.forms import ForgotUsernameForm from geonode.base.views import user_and_group_permission +from geonode.base.middleware import SESSION_LANG_KEY from dal import autocomplete @@ -171,9 +172,6 @@ def get_queryset(self): return qs -SESSION_LANG_KEY = "language_override" - - @require_POST def set_session_language(request): lang_code = request.POST.get("language") # LANGUAGE_QUERY_PARAMETER is "language" From da06037396468727c8b7bc1632b43bcfaa127028 Mon Sep 17 00:00:00 2001 From: gpetrak Date: Mon, 20 Apr 2026 16:31:13 +0300 Subject: [PATCH 5/6] writing tests --- geonode/base/middleware.py | 8 ++++ geonode/people/adapters.py | 1 + geonode/people/test_adapters.py | 11 ++++++ geonode/people/tests.py | 67 +++++++++++++++++++++++++++++++++ 4 files changed, 87 insertions(+) diff --git a/geonode/base/middleware.py b/geonode/base/middleware.py index 730441f2643..ab4ed53e03b 100644 --- a/geonode/base/middleware.py +++ b/geonode/base/middleware.py @@ -104,6 +104,14 @@ def process_view(self, request, view_func, view_args, view_kwargs): class ProfileLanguageMiddleware(MiddlewareMixin): + """ + The middleware is designed so that the session language + (set via the UI switcher) takes precedence over the user's + stored language. The profile language is only used to initialize + the session when no override exists, ensuring that user changes + are preserved for the duration of the session. + """ + def process_request(self, request): if not request.user.is_authenticated: return None diff --git a/geonode/people/adapters.py b/geonode/people/adapters.py index 31ae2557f67..60dbee3a076 100644 --- a/geonode/people/adapters.py +++ b/geonode/people/adapters.py @@ -100,6 +100,7 @@ def update_profile(sociallogin): "profile", "voice", "zipcode", + "language", ) for field in profile_fields: try: diff --git a/geonode/people/test_adapters.py b/geonode/people/test_adapters.py index c70fea439f6..1f70c4d1f69 100644 --- a/geonode/people/test_adapters.py +++ b/geonode/people/test_adapters.py @@ -51,6 +51,7 @@ def setUp(self): self.fake_profile = "phony_profile" self.fake_voice = "phony_voice" self.fake_zipcode = "phony_zipcode" + self.fake_language = "phony_language" mock_social_login_class = mock.MagicMock(spec="allauth.socialaccount.models.SocialLogin") self.mock_social_login = mock_social_login_class.return_value self.mock_social_login.user = self.fake_user @@ -69,6 +70,7 @@ def setUp(self): self.mock_extractor.extract_profile.return_value = self.fake_profile self.mock_extractor.extract_voice.return_value = self.fake_voice self.mock_extractor.extract_zipcode.return_value = self.fake_zipcode + self.mock_extractor.extract_language.return_value = self.fake_language @mock.patch.object(adapters, "get_data_extractor") @mock.patch("geonode.people.adapters.user_field", autospec=True) @@ -190,6 +192,15 @@ def test_update_profile_covers_zipcode_field(self, mock_user_field, mock_get_ext expected_call = mock.call(self.fake_user, "zipcode", self.fake_zipcode) self.assertIn(expected_call, args_list) + @mock.patch.object(adapters, "get_data_extractor") + @mock.patch("geonode.people.adapters.user_field", autospec=True) + def test_update_profile_covers_language_field(self, mock_user_field, mock_get_extractor): + mock_get_extractor.return_value = self.mock_extractor + mock_user_field.return_value = None + adapters.update_profile(self.mock_social_login) + self.mock_extractor.extract_language.assert_called_once_with(self.mock_social_login.account.extra_data) + mock_user_field.assert_any_call(self.fake_user, "language", self.fake_language) + class SiteAllowsSignupTestCase(TestCase): def setUp(self): diff --git a/geonode/people/tests.py b/geonode/people/tests.py index 8bea76afbfc..097d787c22c 100644 --- a/geonode/people/tests.py +++ b/geonode/people/tests.py @@ -1322,3 +1322,70 @@ def test_migrate_sha1_passwords(self): self.assertTrue(user.check_password("password")) # after checking it should be migrated to default hash self.assertTrue(user.password.startswith("pbkdf2_sha1")) + + def test_language_switcher_sets_session_override(self): + response = self.client.post( + "/i18n/setlang/", + data={"language": "it", "next": "/"}, + follow=False, + ) + self.assertEqual(response.status_code, 302) + session = self.client.session + self.assertEqual(session["language_override"], "it") + + def test_authenticated_user_language_switch_overrides_session_only(self): + + user = get_user_model().objects.get(username="bobby") + # set DB language to something different + user.language = "en" + user.save(update_fields=["language"]) + + # login + self.client.login(username="bobby", password="bob") + + # change language via switcher + response = self.client.post( + "/i18n/setlang/", + data={"language": "it", "next": "/"}, + follow=False, + ) + + self.assertEqual(response.status_code, 302) + + # session override should be applied + session = self.client.session + self.assertEqual(session["language_override"], "it") + + # DB should remain unchanged + user.refresh_from_db() + self.assertEqual(user.language, "en") + + def test_authenticated_user_uses_db_language_when_session_not_set(self): + user = get_user_model().objects.get(username="bobby") + user.language = "el" + user.save(update_fields=["language"]) + + self.client.login(username="bobby", password="bob") + + # trigger a real request so middleware runs + self.client.get("/") + + session = self.client.session + self.assertEqual(session.get("language_override"), "el") + + def test_logout_clears_override_and_next_login_uses_db_language(self): + user = get_user_model().objects.get(username="bobby") + user.language = "en" + user.save(update_fields=["language"]) + + self.client.post("/i18n/setlang/", data={"language": "fr", "next": "/"}) + self.client.login(username="bobby", password="bob") + self.assertEqual(self.client.session.get("language_override"), "fr") + + self.client.logout() + self.client.login(username="bobby", password="bob") + + # trigger a real request so middleware runs + self.client.get("/") + + self.assertEqual(self.client.session.get("language_override"), "en") From 109886d0df8439ae567dd48c604d513f2e561d75 Mon Sep 17 00:00:00 2001 From: gpetrak Date: Wed, 22 Apr 2026 13:06:51 +0300 Subject: [PATCH 6/6] using topbar switcher to store the lang to the db --- geonode/base/middleware.py | 16 +------- geonode/people/admin.py | 1 - geonode/people/forms/__init__.py | 6 +++ .../people/templates/people/profile_edit.html | 2 +- geonode/people/tests.py | 39 +++++-------------- geonode/people/views.py | 9 ++++- geonode/settings.py | 3 ++ 7 files changed, 28 insertions(+), 48 deletions(-) diff --git a/geonode/base/middleware.py b/geonode/base/middleware.py index ab4ed53e03b..496d971067a 100644 --- a/geonode/base/middleware.py +++ b/geonode/base/middleware.py @@ -104,25 +104,11 @@ def process_view(self, request, view_func, view_args, view_kwargs): class ProfileLanguageMiddleware(MiddlewareMixin): - """ - The middleware is designed so that the session language - (set via the UI switcher) takes precedence over the user's - stored language. The profile language is only used to initialize - the session when no override exists, ensuring that user changes - are preserved for the duration of the session. - """ - def process_request(self, request): if not request.user.is_authenticated: return None - lang = request.session.get(SESSION_LANG_KEY) - - if not lang: - lang = getattr(request.user, "language", None) - if lang: - request.session[SESSION_LANG_KEY] = lang - + lang = getattr(request.user, "language", None) if lang: translation.activate(lang) request.LANGUAGE_CODE = lang diff --git a/geonode/people/admin.py b/geonode/people/admin.py index bdf1e427df0..ef142c7e411 100644 --- a/geonode/people/admin.py +++ b/geonode/people/admin.py @@ -68,7 +68,6 @@ class ProfileAdmin(admin.ModelAdmin): "area", "zipcode", "country", - "language", "keywords", ) }, diff --git a/geonode/people/forms/__init__.py b/geonode/people/forms/__init__.py index a2310ab9af7..9e7fefb9e14 100644 --- a/geonode/people/forms/__init__.py +++ b/geonode/people/forms/__init__.py @@ -23,6 +23,7 @@ from django.contrib.auth import get_user_model from django.contrib.auth.forms import UserCreationForm, UserChangeForm from django.utils.translation import gettext_lazy as _ +from django.conf import settings # Ported in from django-registration attrs_dict = {"class": "required"} @@ -49,6 +50,11 @@ class ProfileForm(forms.ModelForm): label=_("Keywords"), required=False, help_text=_("A space or comma-separated list of keywords") ) + language = forms.ChoiceField( + label=_("Language"), + choices=settings.PROFILE_LANGUAGE_CHOICES, + ) + class Meta: model = get_user_model() # Explicitly listing fields instead of exclude them diff --git a/geonode/people/templates/people/profile_edit.html b/geonode/people/templates/people/profile_edit.html index 7592f0f50a4..f02e55e397e 100644 --- a/geonode/people/templates/people/profile_edit.html +++ b/geonode/people/templates/people/profile_edit.html @@ -35,7 +35,7 @@

{% trans "Edit Profile for" %} {{ profile.username }}

+ title="{% trans 'Your preferred interface language. Changing this here or via the top-bar menu will permanently update your profile settings across all devices.' %}"> {% endif %} diff --git a/geonode/people/tests.py b/geonode/people/tests.py index 097d787c22c..b1aaab1b015 100644 --- a/geonode/people/tests.py +++ b/geonode/people/tests.py @@ -1333,17 +1333,13 @@ def test_language_switcher_sets_session_override(self): session = self.client.session self.assertEqual(session["language_override"], "it") - def test_authenticated_user_language_switch_overrides_session_only(self): - + def test_authenticated_user_language_switch_updates_session_and_db(self): user = get_user_model().objects.get(username="bobby") - # set DB language to something different user.language = "en" user.save(update_fields=["language"]) - # login self.client.login(username="bobby", password="bob") - # change language via switcher response = self.client.post( "/i18n/setlang/", data={"language": "it", "next": "/"}, @@ -1351,41 +1347,26 @@ def test_authenticated_user_language_switch_overrides_session_only(self): ) self.assertEqual(response.status_code, 302) + self.assertEqual(self.client.session["language_override"], "it") - # session override should be applied - session = self.client.session - self.assertEqual(session["language_override"], "it") - - # DB should remain unchanged user.refresh_from_db() - self.assertEqual(user.language, "en") + self.assertEqual(user.language, "it") - def test_authenticated_user_uses_db_language_when_session_not_set(self): + def test_logout_and_next_login_keep_updated_db_language(self): user = get_user_model().objects.get(username="bobby") - user.language = "el" + user.language = "en" user.save(update_fields=["language"]) self.client.login(username="bobby", password="bob") - # trigger a real request so middleware runs - self.client.get("/") - - session = self.client.session - self.assertEqual(session.get("language_override"), "el") - - def test_logout_clears_override_and_next_login_uses_db_language(self): - user = get_user_model().objects.get(username="bobby") - user.language = "en" - user.save(update_fields=["language"]) - self.client.post("/i18n/setlang/", data={"language": "fr", "next": "/"}) - self.client.login(username="bobby", password="bob") + + user.refresh_from_db() + self.assertEqual(user.language, "fr") self.assertEqual(self.client.session.get("language_override"), "fr") self.client.logout() self.client.login(username="bobby", password="bob") - # trigger a real request so middleware runs - self.client.get("/") - - self.assertEqual(self.client.session.get("language_override"), "en") + user.refresh_from_db() + self.assertEqual(user.language, "fr") diff --git a/geonode/people/views.py b/geonode/people/views.py index d9350532fcd..ad01bae0743 100644 --- a/geonode/people/views.py +++ b/geonode/people/views.py @@ -174,10 +174,15 @@ def get_queryset(self): @require_POST def set_session_language(request): - lang_code = request.POST.get("language") # LANGUAGE_QUERY_PARAMETER is "language" + raw_lang = request.POST.get("language") + lang_code = raw_lang.split("-")[0].lower() if raw_lang else None if lang_code and check_for_language(lang_code): request.session[SESSION_LANG_KEY] = lang_code - # Delegate all redirect/cookie logic to Django + if request.user.is_authenticated and hasattr(request.user, "language"): + if request.user.language != lang_code: + request.user.language = lang_code + request.user.save(update_fields=["language"]) + return django_set_language(request) diff --git a/geonode/settings.py b/geonode/settings.py index d649d68d236..9133a3205a0 100644 --- a/geonode/settings.py +++ b/geonode/settings.py @@ -1487,6 +1487,9 @@ def get_geonode_catalogue_service(): ("it-it", "Italiano"), ) + # This setting includes supported Maptstore language choices in a DB-based format + PROFILE_LANGUAGE_CHOICES = tuple((code.split("-")[0].lower(), label) for code, label in MAPSTORE_DEFAULT_LANGUAGES) + if os.getenv("LANGUAGES"): # Map given languages to mapstore supported languages. LANGUAGES = tuple(