diff --git a/enterprise_catalog/apps/api/v1/constants.py b/enterprise_catalog/apps/api/v1/constants.py index 45476707..e1908499 100644 --- a/enterprise_catalog/apps/api/v1/constants.py +++ b/enterprise_catalog/apps/api/v1/constants.py @@ -10,3 +10,7 @@ class SegmentEvents: AI_CURATIONS_TASK_COMPLETED = 'edx.server.enterprise-catalog.ai-curations.task.completed' AI_CURATIONS_RESULTS_FOUND = 'edx.server.enterprise-catalog.ai-curations.results-found' AI_CURATIONS_RESULTS_NOT_FOUND = 'edx.server.enterprise-catalog.ai-curations.results-not-found' + + +DEFAULT_TRANSLATION_LANGUAGE = 'en' +AVAILABLE_TRANSLATION_LANGUAGES = ['es'] diff --git a/enterprise_catalog/apps/api/v1/serializers.py b/enterprise_catalog/apps/api/v1/serializers.py index bf0aa8cc..87839be5 100644 --- a/enterprise_catalog/apps/api/v1/serializers.py +++ b/enterprise_catalog/apps/api/v1/serializers.py @@ -2,9 +2,14 @@ from re import findall, search from django.db import IntegrityError, models +from django.db.models import Prefetch from rest_framework import serializers, status from enterprise_catalog.apps.academy.models import Academy, Tag +from enterprise_catalog.apps.api.v1.constants import ( + AVAILABLE_TRANSLATION_LANGUAGES, + DEFAULT_TRANSLATION_LANGUAGE, +) from enterprise_catalog.apps.api.v1.utils import ( get_archived_content_count, get_enterprise_utm_context, @@ -23,6 +28,7 @@ from enterprise_catalog.apps.catalog.models import ( CatalogQuery, ContentMetadata, + ContentTranslation, EnterpriseCatalog, ) from enterprise_catalog.apps.catalog.utils import get_content_filter_hash @@ -360,6 +366,7 @@ class HighlightedContentSerializer(serializers.ModelSerializer): Serializer for the `HighlightedContent` model. """ aggregation_key = serializers.SerializerMethodField() + title = serializers.SerializerMethodField() class Meta: model = HighlightedContent @@ -382,6 +389,22 @@ def get_aggregation_key(self, obj): """ return obj.aggregation_key + def get_title(self, obj): + """ + Returns the title, preferring translation if language is supported and available. + """ + lang = self.context.get('lang') + title = obj.title + + if not lang or lang == DEFAULT_TRANSLATION_LANGUAGE or lang not in AVAILABLE_TRANSLATION_LANGUAGES: + return title + + translations = obj.content_metadata.translations.all() + if translations and translations[0].title: + return translations[0].title + + return title + class HighlightSetSerializer(serializers.ModelSerializer): """ @@ -406,8 +429,20 @@ def get_highlighted_content(self, obj): """ Returns the data for the associated content included in this HighlightSet object. """ + lang = self.context.get('lang') + qs = obj.highlighted_content.order_by('created').select_related('content_metadata') - return HighlightedContentSerializer(qs, many=True).data + + if lang in AVAILABLE_TRANSLATION_LANGUAGES and lang != DEFAULT_TRANSLATION_LANGUAGE: + # Only prefetches translations if a supported non-English language is requested. + qs = qs.prefetch_related( + Prefetch( + 'content_metadata__translations', + queryset=ContentTranslation.objects.filter(language_code=lang) + ) + ) + + return HighlightedContentSerializer(qs, many=True, context=self.context).data class EnterpriseCurationConfigSerializer(serializers.ModelSerializer): diff --git a/enterprise_catalog/apps/api/v1/tests/test_curation_views.py b/enterprise_catalog/apps/api/v1/tests/test_curation_views.py index 396da27c..422ab45f 100644 --- a/enterprise_catalog/apps/api/v1/tests/test_curation_views.py +++ b/enterprise_catalog/apps/api/v1/tests/test_curation_views.py @@ -23,6 +23,7 @@ from enterprise_catalog.apps.catalog.constants import COURSE, PROGRAM from enterprise_catalog.apps.catalog.tests.factories import ( ContentMetadataFactory, + ContentTranslationFactory, ) from enterprise_catalog.apps.curation.tests.factories import ( EnterpriseCurationConfigFactory, @@ -526,6 +527,171 @@ def test_delete_not_allowed(self, is_catalog_staff, is_role_assigned_via_jwt): assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED +@ddt.ddt +class HighlightSetMultilingualTests(CurationAPITestBase): + """ + Test multilingual support in HighlightSetReadOnlyViewSet. + """ + def setUp(self): + super().setUp() + + # Create Spanish translations for the first three content items + self.spanish_titles = [ + 'Título en Español 1', + 'Título en Español 2', + 'Título en Español 3', + ] + self.translations = [] + for idx, content_metadata in enumerate(self.highlighted_content_metadata_one[:3]): + translation = ContentTranslationFactory( + content_metadata=content_metadata, + language_code='es', + title=self.spanish_titles[idx] + ) + self.translations.append(translation) + + def test_list_with_spanish_language_parameter(self): + """ + Test that requesting highlight sets with lang=es returns Spanish translations. + """ + url = reverse('api:v1:highlight-sets-list') + f'?enterprise_customer={self.enterprise_uuid}&lang=es' + self.set_up_catalog_learner() + + response = self.client.get(url) + assert response.status_code == status.HTTP_200_OK + + highlight_sets_results = response.json()['results'] + assert len(highlight_sets_results) == 1 + + highlighted_content = highlight_sets_results[0]['highlighted_content'] + # First three should have Spanish titles + for idx in range(3): + assert highlighted_content[idx]['title'] == self.spanish_titles[idx] + + # Last two should have original English titles (no translation) + for idx in range(3, 5): + original_title = self.highlighted_content_metadata_one[idx].json_metadata['title'] + assert highlighted_content[idx]['title'] == original_title + + def test_list_with_english_language_parameter(self): + """ + Test that requesting highlight sets with lang=en returns original English titles. + """ + url = reverse('api:v1:highlight-sets-list') + f'?enterprise_customer={self.enterprise_uuid}&lang=en' + self.set_up_catalog_learner() + + response = self.client.get(url) + assert response.status_code == status.HTTP_200_OK + + highlight_sets_results = response.json()['results'] + highlighted_content = highlight_sets_results[0]['highlighted_content'] + + # All should have original English titles + for idx in range(5): + original_title = self.highlighted_content_metadata_one[idx].json_metadata['title'] + assert highlighted_content[idx]['title'] == original_title + + def test_list_with_unsupported_language(self): + """ + Test that requesting with an unsupported language defaults to English. + """ + url = reverse('api:v1:highlight-sets-list') + f'?enterprise_customer={self.enterprise_uuid}&lang=fr' + self.set_up_catalog_learner() + + response = self.client.get(url) + assert response.status_code == status.HTTP_200_OK + + highlight_sets_results = response.json()['results'] + highlighted_content = highlight_sets_results[0]['highlighted_content'] + + # All should have original English titles (default behavior) + for idx in range(5): + original_title = self.highlighted_content_metadata_one[idx].json_metadata['title'] + assert highlighted_content[idx]['title'] == original_title + + def test_list_without_language_parameter(self): + """ + Test that requesting highlight sets without lang parameter defaults to English. + """ + url = reverse('api:v1:highlight-sets-list') + f'?enterprise_customer={self.enterprise_uuid}' + self.set_up_catalog_learner() + + response = self.client.get(url) + assert response.status_code == status.HTTP_200_OK + + highlight_sets_results = response.json()['results'] + highlighted_content = highlight_sets_results[0]['highlighted_content'] + + # All should have original English titles (default behavior) + for idx in range(5): + original_title = self.highlighted_content_metadata_one[idx].json_metadata['title'] + assert highlighted_content[idx]['title'] == original_title + + def test_detail_with_spanish_language_parameter(self): + """ + Test that retrieving a specific highlight set with lang=es returns Spanish translations. + """ + detail_url = reverse('api:v1:highlight-sets-detail', kwargs={'uuid': str(self.highlight_set_one.uuid)}) + detail_url += '?lang=es' + self.set_up_catalog_learner() + + response = self.client.get(detail_url) + assert response.status_code == status.HTTP_200_OK + + highlighted_content = response.json()['highlighted_content'] + + # First three should have Spanish titles + for idx in range(3): + assert highlighted_content[idx]['title'] == self.spanish_titles[idx] + + # Last two should have original English titles (no translation) + for idx in range(3, 5): + original_title = self.highlighted_content_metadata_one[idx].json_metadata['title'] + assert highlighted_content[idx]['title'] == original_title + + def test_detail_with_unsupported_language(self): + """ + Test that requesting with an unsupported language defaults to English. + """ + detail_url = reverse('api:v1:highlight-sets-detail', kwargs={'uuid': str(self.highlight_set_one.uuid)}) + detail_url += '?lang=fr' # French is not in AVAILABLE_TRANSLATION_LANGUAGES + self.set_up_catalog_learner() + + response = self.client.get(detail_url) + assert response.status_code == status.HTTP_200_OK + + highlighted_content = response.json()['highlighted_content'] + + # All should have original English titles (default behavior) + for idx in range(5): + original_title = self.highlighted_content_metadata_one[idx].json_metadata['title'] + assert highlighted_content[idx]['title'] == original_title + + def test_translation_with_empty_title(self): + """ + Test that if a translation exists but has an empty title, it falls back to the original. + """ + # Create a translation with empty title + content_metadata = self.highlighted_content_metadata_one[4] + ContentTranslationFactory( + content_metadata=content_metadata, + language_code='es', + title='' # Empty title + ) + + detail_url = reverse('api:v1:highlight-sets-detail', kwargs={'uuid': str(self.highlight_set_one.uuid)}) + detail_url += '?lang=es' + self.set_up_catalog_learner() + + response = self.client.get(detail_url) + assert response.status_code == status.HTTP_200_OK + + highlighted_content = response.json()['highlighted_content'] + # Should fall back to original title when translation title is empty + original_title = self.highlighted_content_metadata_one[4].json_metadata['title'] + assert highlighted_content[4]['title'] == original_title + + @ddt.ddt class HighlightSetViewSetTests(CurationAPITestBase): """ diff --git a/enterprise_catalog/apps/api/v1/tests/test_serializers.py b/enterprise_catalog/apps/api/v1/tests/test_serializers.py index bdcbcb19..14f5674f 100644 --- a/enterprise_catalog/apps/api/v1/tests/test_serializers.py +++ b/enterprise_catalog/apps/api/v1/tests/test_serializers.py @@ -6,13 +6,21 @@ from enterprise_catalog.apps.api.v1.serializers import ( ContentMetadataSerializer, + HighlightedContentSerializer, + HighlightSetSerializer, find_and_modify_catalog_query, ) from enterprise_catalog.apps.catalog.models import CatalogQuery from enterprise_catalog.apps.catalog.tests.factories import ( ContentMetadataFactory, + ContentTranslationFactory, ) from enterprise_catalog.apps.catalog.utils import get_content_filter_hash +from enterprise_catalog.apps.curation.tests.factories import ( + EnterpriseCurationConfigFactory, + HighlightedContentFactory, + HighlightSetFactory, +) class ContentMetadataSerializerTests(TestCase): @@ -150,3 +158,190 @@ def test_title_duplication_causes_error(self): uuid_to_update, title ) + + +class HighlightedContentSerializerTests(TestCase): + """ + Tests for the HighlightedContentSerializer and multilingual support + """ + + def setUp(self): + super().setUp() + + self.serializer_class = HighlightedContentSerializer + + # Create content metadata + self.content_metadata = ContentMetadataFactory() + self.original_title = self.content_metadata.json_metadata['title'] + + # Create Spanish translation + self.spanish_title = 'Título en Español' + self.translation = ContentTranslationFactory( + content_metadata=self.content_metadata, + language_code='es', + title=self.spanish_title + ) + + # Create highlighted content + self.curation_config = EnterpriseCurationConfigFactory() + self.highlight_set = HighlightSetFactory(enterprise_curation=self.curation_config) + self.highlighted_content = HighlightedContentFactory( + catalog_highlight_set=self.highlight_set, + content_metadata=self.content_metadata + ) + + def test_get_title_with_spanish_language(self): + """ + Test that get_title returns Spanish translation when lang=es in context. + """ + context = {'lang': 'es'} + serializer = self.serializer_class(self.highlighted_content, context=context) + + assert serializer.data['title'] == self.spanish_title + + def test_get_title_with_english_language(self): + """ + Test that get_title returns original title when lang=en in context. + """ + context = {'lang': 'en'} + serializer = self.serializer_class(self.highlighted_content, context=context) + + assert serializer.data['title'] == self.original_title + + def test_get_title_without_language_context(self): + """ + Test that get_title returns original title when no lang in context. + """ + context = {} + serializer = self.serializer_class(self.highlighted_content, context=context) + + assert serializer.data['title'] == self.original_title + + def test_get_title_with_no_translation_available(self): + """ + Test that get_title falls back to original title when no translation exists. + """ + + # Create content without translation + content_without_translation = ContentMetadataFactory() + highlighted_content_no_translation = HighlightedContentFactory( + catalog_highlight_set=self.highlight_set, + content_metadata=content_without_translation + ) + + context = {'lang': 'es'} + serializer = self.serializer_class(highlighted_content_no_translation, context=context) + + original_title = content_without_translation.json_metadata['title'] + assert serializer.data['title'] == original_title + + def test_get_title_with_empty_translation_title(self): + """ + Test that get_title falls back to original when translation title is empty. + """ + + # Create content with empty translation title + content_metadata = ContentMetadataFactory() + ContentTranslationFactory( + content_metadata=content_metadata, + language_code='es', + title='' # Empty title + ) + highlighted_content = HighlightedContentFactory( + catalog_highlight_set=self.highlight_set, + content_metadata=content_metadata + ) + + context = {'lang': 'es'} + serializer = self.serializer_class(highlighted_content, context=context) + + original_title = content_metadata.json_metadata['title'] + assert serializer.data['title'] == original_title + + +class HighlightSetSerializerTests(TestCase): + """ + Tests for the HighlightSetSerializer and multilingual support + """ + + def setUp(self): + super().setUp() + + self.serializer_class = HighlightSetSerializer + + # Create curation config and highlight set + self.curation_config = EnterpriseCurationConfigFactory() + self.highlight_set = HighlightSetFactory(enterprise_curation=self.curation_config) + + # Create multiple content items with translations + self.content_items = [] + self.spanish_titles = [] + for i in range(3): + content = ContentMetadataFactory() + spanish_title = f'Título en Español {i + 1}' + ContentTranslationFactory( + content_metadata=content, + language_code='es', + title=spanish_title + ) + HighlightedContentFactory( + catalog_highlight_set=self.highlight_set, + content_metadata=content + ) + self.content_items.append(content) + self.spanish_titles.append(spanish_title) + + def test_get_highlighted_content_with_spanish_language(self): + """ + Test that get_highlighted_content returns Spanish translations when lang=es. + """ + context = {'lang': 'es'} + serializer = self.serializer_class(self.highlight_set, context=context) + + highlighted_content = serializer.data['highlighted_content'] + + for idx, item in enumerate(highlighted_content): + assert item['title'] == self.spanish_titles[idx] + + def test_get_highlighted_content_with_english_language(self): + """ + Test that get_highlighted_content returns original titles when lang=en. + """ + context = {'lang': 'en'} + serializer = self.serializer_class(self.highlight_set, context=context) + + highlighted_content = serializer.data['highlighted_content'] + + for idx, item in enumerate(highlighted_content): + original_title = self.content_items[idx].json_metadata['title'] + assert item['title'] == original_title + + def test_get_highlighted_content_prefetch_optimization(self): + """ + Test that prefetch_related is used for supported languages. + This is a smoke test to ensure the optimization doesn't break functionality. + """ + context = {'lang': 'es'} + serializer = self.serializer_class(self.highlight_set, context=context) + + # If prefetch works correctly, this should not raise any errors + highlighted_content = serializer.data['highlighted_content'] + assert len(highlighted_content) == 3 + + # Verify Spanish titles are returned + for idx, item in enumerate(highlighted_content): + assert item['title'] == self.spanish_titles[idx] + + def test_get_highlighted_content_without_language(self): + """ + Test that get_highlighted_content works without lang in context. + """ + context = {} + serializer = self.serializer_class(self.highlight_set, context=context) + + highlighted_content = serializer.data['highlighted_content'] + + # Should return original titles + for idx, item in enumerate(highlighted_content): + original_title = self.content_items[idx].json_metadata['title'] + assert item['title'] == original_title diff --git a/enterprise_catalog/apps/api/v1/views/curation/highlights.py b/enterprise_catalog/apps/api/v1/views/curation/highlights.py index f1911e52..5ae2d5c5 100644 --- a/enterprise_catalog/apps/api/v1/views/curation/highlights.py +++ b/enterprise_catalog/apps/api/v1/views/curation/highlights.py @@ -18,7 +18,10 @@ CURATION_CONFIG_READ_ONLY_VIEW_CACHE_TIMEOUT_SECONDS, HIGHLIGHT_SET_READ_ONLY_VIEW_CACHE_TIMEOUT_SECONDS, ) -from enterprise_catalog.apps.api.v1.constants import SegmentEvents +from enterprise_catalog.apps.api.v1.constants import ( + DEFAULT_TRANSLATION_LANGUAGE, + SegmentEvents, +) from enterprise_catalog.apps.api.v1.event_utils import ( track_highlight_set_changes, ) @@ -294,6 +297,14 @@ class HighlightSetReadOnlyViewSet(HighlightSetBaseViewSet, viewsets.ReadOnlyMode def list(self, request, *args, **kwargs): return super().list(request, *args, **kwargs) + def get_serializer_context(self): + """ + Add language parameter from query string to serializer context. + """ + context = super().get_serializer_context() + context['lang'] = self.request.query_params.get('lang', DEFAULT_TRANSLATION_LANGUAGE) + return context + class HighlightSetViewSet(HighlightSetBaseViewSet, viewsets.ModelViewSet): """ diff --git a/enterprise_catalog/apps/catalog/tests/factories.py b/enterprise_catalog/apps/catalog/tests/factories.py index 93ef6394..608035d7 100644 --- a/enterprise_catalog/apps/catalog/tests/factories.py +++ b/enterprise_catalog/apps/catalog/tests/factories.py @@ -15,6 +15,7 @@ from enterprise_catalog.apps.catalog.models import ( CatalogQuery, ContentMetadata, + ContentTranslation, EnterpriseCatalog, EnterpriseCatalogFeatureRole, EnterpriseCatalogRoleAssignment, @@ -192,6 +193,24 @@ def _json_metadata(self): return json_metadata +class ContentTranslationFactory(factory.django.DjangoModelFactory): + """ + Test factory for the `ContentTranslation` model + """ + class Meta: + model = ContentTranslation + + content_metadata = factory.SubFactory(ContentMetadataFactory) + language_code = 'es' + title = factory.Faker('sentence', nb_words=4) + short_description = factory.Faker('paragraph', nb_sentences=2) + full_description = factory.Faker('paragraph', nb_sentences=5) + outcome = factory.Faker('paragraph', nb_sentences=3) + prerequisites = factory.Faker('paragraph', nb_sentences=2) + subtitle = factory.Faker('sentence', nb_words=6) + source_hash = factory.Faker('sha256') + + class RestrictedCourseMetadataFactory(factory.django.DjangoModelFactory): """ Test factory for the `RestrictedCourseMetadata` model.