From adf43a15a151d8aa0043f4e99c2e980c808624d1 Mon Sep 17 00:00:00 2001 From: Niraj Adhikari Date: Mon, 23 Feb 2026 14:25:59 +0545 Subject: [PATCH 1/5] [Fixes #13971 ] Added optional query parameter to return all translated fields in resource APIs --- geonode/metadata/multilang/serializers.py | 13 +++++++++++-- geonode/metadata/multilang/utils.py | 11 +++++++++++ geonode/metadata/multilang/views.py | 13 ++++++++++--- 3 files changed, 32 insertions(+), 5 deletions(-) diff --git a/geonode/metadata/multilang/serializers.py b/geonode/metadata/multilang/serializers.py index d8bf86ede2d..d090a73fae8 100644 --- a/geonode/metadata/multilang/serializers.py +++ b/geonode/metadata/multilang/serializers.py @@ -2,6 +2,7 @@ from django.conf import settings from rest_framework import serializers +from geonode.metadata.multilang import utils as multi logger = logging.getLogger(__name__) @@ -12,12 +13,20 @@ class MultiLangOutputMixin(serializers.BaseSerializer): def to_representation(self, instance): representation = super().to_representation(instance) + request = self.context.get('request') + include_i18n = request.query_params.get('include_i18n', 'false').lower() == 'true' if request else False if settings.MULTILANG_FIELDS and hasattr(instance, "_multilang_sparse_prefetch"): for sparse in instance._multilang_sparse_prefetch: base_field_name = sparse.name[:-13] # name_multilang_xx + lang_code = sparse.name[-2:] + if base_field_name in representation: - logger.debug(f"setting into {instance} field {base_field_name} --> {sparse.value}") - representation[base_field_name] = sparse.value + if not include_i18n: + representation[base_field_name] = sparse.value + else: + if lang_code == multi.get_language(request): + representation[base_field_name] = sparse.value + representation[f"{base_field_name}_{lang_code}"] = sparse.value return representation diff --git a/geonode/metadata/multilang/utils.py b/geonode/metadata/multilang/utils.py index 83c62c4aec2..dbdc665023f 100644 --- a/geonode/metadata/multilang/utils.py +++ b/geonode/metadata/multilang/utils.py @@ -48,3 +48,14 @@ def get_language(request): language = language.split("-")[0] # normalize return language + + +def get_all_multilang_fields(): + all_fields = [] + langs = get_2letters_languages() + + for field in settings.MULTILANG_FIELDS: + for lang in langs: + all_fields.append(get_multilang_field_name(field, lang)) + + return all_fields diff --git a/geonode/metadata/multilang/views.py b/geonode/metadata/multilang/views.py index dc92ed70530..114256eef45 100644 --- a/geonode/metadata/multilang/views.py +++ b/geonode/metadata/multilang/views.py @@ -17,15 +17,22 @@ class MultiLangViewMixin: def get_queryset(self): """Adds a Prefetch to include localized fields in one query.""" - lang = multi.get_language(getattr(self, "request", None)) - field_names = multi.get_multilang_fields_for_lang(lang) + + request = getattr(self, "request", None) + include_i18n = request.query_params.get('include_i18n', 'false').lower() == 'true' if request else False + + if include_i18n: + field_names = multi.get_all_multilang_fields() + else: + lang = multi.get_language(request) + field_names = multi.get_multilang_fields_for_lang(lang) qs = super().get_queryset() if not field_names: return qs - # Prefetch all localized rows for this language + # Prefetch the localized rows prefetch = Prefetch( "sparsefield_set", # this must match related_name on FK queryset=SparseField.objects.filter(name__in=field_names), From ea0ee51a43f2a3f75a934664f39d47338ffa4aa3 Mon Sep 17 00:00:00 2001 From: Niraj Adhikari Date: Mon, 23 Feb 2026 15:26:17 +0545 Subject: [PATCH 2/5] [Fixes #13791] Use GET fallback for query_params in MultiLang mixins --- geonode/metadata/multilang/serializers.py | 6 ++++-- geonode/metadata/multilang/views.py | 3 ++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/geonode/metadata/multilang/serializers.py b/geonode/metadata/multilang/serializers.py index d090a73fae8..3e5662e45ac 100644 --- a/geonode/metadata/multilang/serializers.py +++ b/geonode/metadata/multilang/serializers.py @@ -2,6 +2,7 @@ from django.conf import settings from rest_framework import serializers + from geonode.metadata.multilang import utils as multi @@ -13,8 +14,9 @@ class MultiLangOutputMixin(serializers.BaseSerializer): def to_representation(self, instance): representation = super().to_representation(instance) - request = self.context.get('request') - include_i18n = request.query_params.get('include_i18n', 'false').lower() == 'true' if request else False + request = self.context.get("request") + params = getattr(request, "query_params", None) or getattr(request, "GET", {}) + include_i18n = params.get("include_i18n", "false").lower() == "true" if request else False if settings.MULTILANG_FIELDS and hasattr(instance, "_multilang_sparse_prefetch"): for sparse in instance._multilang_sparse_prefetch: diff --git a/geonode/metadata/multilang/views.py b/geonode/metadata/multilang/views.py index 114256eef45..67f5789eb3f 100644 --- a/geonode/metadata/multilang/views.py +++ b/geonode/metadata/multilang/views.py @@ -19,7 +19,8 @@ def get_queryset(self): """Adds a Prefetch to include localized fields in one query.""" request = getattr(self, "request", None) - include_i18n = request.query_params.get('include_i18n', 'false').lower() == 'true' if request else False + params = getattr(request, "query_params", None) or getattr(request, "GET", {}) + include_i18n = params.get("include_i18n", "false").lower() == "true" if request else False if include_i18n: field_names = multi.get_all_multilang_fields() From 635521c48786265a8a27ab8654ec4b1747eb8bb3 Mon Sep 17 00:00:00 2001 From: Niraj Adhikari Date: Tue, 24 Feb 2026 21:24:30 +0545 Subject: [PATCH 3/5] Improve multilang field mapping and serializer logic --- geonode/metadata/multilang/serializers.py | 28 ++++++++++++++--------- geonode/metadata/multilang/utils.py | 13 ++++------- geonode/metadata/multilang/views.py | 5 ++-- 3 files changed, 25 insertions(+), 21 deletions(-) diff --git a/geonode/metadata/multilang/serializers.py b/geonode/metadata/multilang/serializers.py index 3e5662e45ac..4c8b2fef1d5 100644 --- a/geonode/metadata/multilang/serializers.py +++ b/geonode/metadata/multilang/serializers.py @@ -17,18 +17,24 @@ def to_representation(self, instance): request = self.context.get("request") params = getattr(request, "query_params", None) or getattr(request, "GET", {}) include_i18n = params.get("include_i18n", "false").lower() == "true" if request else False + target_lang = multi.get_language(request) if settings.MULTILANG_FIELDS and hasattr(instance, "_multilang_sparse_prefetch"): - for sparse in instance._multilang_sparse_prefetch: - base_field_name = sparse.name[:-13] # name_multilang_xx - lang_code = sparse.name[-2:] - - if base_field_name in representation: - if not include_i18n: - representation[base_field_name] = sparse.value - else: - if lang_code == multi.get_language(request): - representation[base_field_name] = sparse.value - representation[f"{base_field_name}_{lang_code}"] = sparse.value + multilang_field_map = multi.get_all_multilang_fields() + + sparse_value_map = {sparse.name: sparse.value for sparse in instance._multilang_sparse_prefetch} + + for (base_field_name, lang_code), sparse_field_name in multilang_field_map.items(): + + if sparse_field_name not in sparse_value_map: + continue + + value = sparse_value_map[sparse_field_name] + + if lang_code == target_lang: + representation[base_field_name] = value + + if include_i18n: + representation[f"{base_field_name}_{lang_code}"] = value return representation diff --git a/geonode/metadata/multilang/utils.py b/geonode/metadata/multilang/utils.py index dbdc665023f..b4897d18e13 100644 --- a/geonode/metadata/multilang/utils.py +++ b/geonode/metadata/multilang/utils.py @@ -51,11 +51,8 @@ def get_language(request): def get_all_multilang_fields(): - all_fields = [] - langs = get_2letters_languages() - - for field in settings.MULTILANG_FIELDS: - for lang in langs: - all_fields.append(get_multilang_field_name(field, lang)) - - return all_fields + return { + (field, lang): get_multilang_field_name(field, lang) + for field in settings.MULTILANG_FIELDS + for lang in get_2letters_languages() + } diff --git a/geonode/metadata/multilang/views.py b/geonode/metadata/multilang/views.py index 67f5789eb3f..dc1e8ff49b5 100644 --- a/geonode/metadata/multilang/views.py +++ b/geonode/metadata/multilang/views.py @@ -21,11 +21,12 @@ def get_queryset(self): request = getattr(self, "request", None) params = getattr(request, "query_params", None) or getattr(request, "GET", {}) include_i18n = params.get("include_i18n", "false").lower() == "true" if request else False + lang = multi.get_language(request) if include_i18n: - field_names = multi.get_all_multilang_fields() + field_map = multi.get_all_multilang_fields() + field_names = list(field_map.values()) else: - lang = multi.get_language(request) field_names = multi.get_multilang_fields_for_lang(lang) qs = super().get_queryset() From 9b9f98de2daba4f250c94b38bb48d53f4c2c9a98 Mon Sep 17 00:00:00 2001 From: Niraj Adhikari Date: Tue, 24 Feb 2026 22:59:03 +0545 Subject: [PATCH 4/5] fix test cases --- geonode/metadata/multilang/utils.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/geonode/metadata/multilang/utils.py b/geonode/metadata/multilang/utils.py index b4897d18e13..d92b05d7490 100644 --- a/geonode/metadata/multilang/utils.py +++ b/geonode/metadata/multilang/utils.py @@ -36,11 +36,14 @@ def get_default_language(): def get_language(request): if request: - language = request.query_params.get("lang", None) # explicit query param + params = getattr(request, "query_params", None) or getattr(request, "GET", {}) + language = params.get("lang", None) + if not language: language = getattr(request, "LANGUAGE_CODE", None) # LocaleMiddleware if not language: - language = request.headers.get("Accept-Language", "").split(",")[0] + headers = getattr(request, "headers", {}) + language = headers.get("Accept-Language", "").split(",")[0] else: language = get_default_language() From ed2bf1e4a4d26e386069c29f7640f98a105a6ac8 Mon Sep 17 00:00:00 2001 From: Niraj Adhikari Date: Thu, 26 Feb 2026 14:08:42 +0545 Subject: [PATCH 5/5] Tests for the include_i18n query param --- geonode/documents/api/tests.py | 31 ++++++++++++++++++++++++ geonode/layers/api/tests.py | 31 ++++++++++++++++++++++++ geonode/metadata/tests/test_multilang.py | 17 +++++++++++++ 3 files changed, 79 insertions(+) diff --git a/geonode/documents/api/tests.py b/geonode/documents/api/tests.py index d8d68967eb3..00467a1ce0e 100644 --- a/geonode/documents/api/tests.py +++ b/geonode/documents/api/tests.py @@ -22,6 +22,7 @@ from urllib.parse import urljoin from django.urls import reverse +from django.test import override_settings from rest_framework.test import APITestCase from guardian.shortcuts import assign_perm, get_anonymous_user @@ -30,6 +31,7 @@ from geonode.base.populate_test_data import create_models from geonode.base.enumerations import SOURCE_TYPE_REMOTE from geonode.documents.models import Document +from geonode.metadata.models import SparseField logger = logging.getLogger(__name__) @@ -592,3 +594,32 @@ def test_either_path_or_url_doc(self): actual = self.client.post(self.url, data=payload, format="json") self.assertEqual(400, actual.status_code) self.assertDictEqual(expected, actual.json()) + + def test_documents_api_include_i18n(self): + """ + Ensure that the ?include_i18n parameter returns all localized fields in the Documents API response. + """ + + _document = Document.objects.first() + # Manually create sparse fields for the document to test prefetching/serialization + SparseField.objects.create(resource=_document, name="title_multilang_en", value="English Doc Title") + SparseField.objects.create(resource=_document, name="title_multilang_it", value="Titolo Documento Italiano") + + url = reverse("documents-detail", kwargs={"pk": _document.pk}) + + with override_settings( + LANGUAGES=[("en", "English"), ("it", "Italiano")], + MULTILANG_FIELDS=["title"], + ): + query_params = "include_i18n=true&lang=it" + response = self.client.get(f"{url}?{query_params}", format="json") + + self.assertEqual(response.status_code, 200) + data = response.data["document"] + + self.assertEqual(data["title"], "Titolo Documento Italiano") + + self.assertIn("title_en", data) + self.assertEqual(data["title_en"], "English Doc Title") + self.assertIn("title_it", data) + self.assertEqual(data["title_it"], "Titolo Documento Italiano") diff --git a/geonode/layers/api/tests.py b/geonode/layers/api/tests.py index 4e6c69d11d2..a68e61c68ec 100644 --- a/geonode/layers/api/tests.py +++ b/geonode/layers/api/tests.py @@ -33,6 +33,7 @@ from geonode.base.populate_test_data import create_models, create_single_dataset from geonode.layers.models import Attribute, Dataset from geonode.maps.models import Map, MapLayer +from geonode.metadata.models import SparseField logger = logging.getLogger(__name__) @@ -692,3 +693,33 @@ def test_download_api(self): self.assertEqual(download_url_data["default"], True) self.assertEqual(download_url_data["ajax_safe"], False) self.assertEqual(download_url_data["url"], "https://myoriginal.org") + + def test_datasets_api_include_i18n(self): + """ + Ensure that the ?include_i18n parameter returns all localized fields in the API response. + """ + + _dataset = Dataset.objects.first() + + # Manually create the sparse fields required for the test + SparseField.objects.create(resource=_dataset, name="title_multilang_en", value="English Title") + SparseField.objects.create(resource=_dataset, name="title_multilang_it", value="Titolo Italiano") + + url = reverse("datasets-detail", kwargs={"pk": _dataset.pk}) + + with override_settings( + LANGUAGES=[("en", "English"), ("it", "Italiano")], + MULTILANG_FIELDS=["title"], + ): + query_params = "include_i18n=true&lang=it" + response = self.client.get(f"{url}?{query_params}", format="json") + + self.assertEqual(response.status_code, 200) + data = response.data["dataset"] + + self.assertEqual(data["title"], "Titolo Italiano") + + self.assertIn("title_en", data) + self.assertEqual(data["title_en"], "English Title") + self.assertIn("title_it", data) + self.assertEqual(data["title_it"], "Titolo Italiano") diff --git a/geonode/metadata/tests/test_multilang.py b/geonode/metadata/tests/test_multilang.py index 10f887bd4dc..29c5cda1d3f 100644 --- a/geonode/metadata/tests/test_multilang.py +++ b/geonode/metadata/tests/test_multilang.py @@ -136,6 +136,23 @@ def test_default_preserialization(self): self.assertEqual("title_fake", instance["title_multilang_it"]) self.assertIsNone(instance["title_multilang_en"]) + def test_get_all_multilang_fields_structure(self): + """ + Verify the new dictionary structure and ensure it identifies + all configured field/language combinations. + """ + with override_settings( + LANGUAGES=[("en", "English"), ("it", "Italiano")], + MULTILANG_FIELDS=["title", "abstract"], + ): + field_map = multi.get_all_multilang_fields() + + self.assertIsInstance(field_map, dict) + self.assertEqual(len(field_map), 4) + + self.assertEqual(field_map[("title", "en")], "title_multilang_en") + self.assertEqual(field_map[("abstract", "it")], "abstract_multilang_it") + @patch("geonode.base.models.ResourceBase.get_real_instance_class") @patch("geonode.indexing.manager.TSVectorIndexManager.update_index") @patch("geonode.metadata.handlers.sparse.SparseHandler.update_resource")