diff --git a/course_discovery/apps/api/serializers.py b/course_discovery/apps/api/serializers.py index 91430988ca..05d5b7b3b7 100644 --- a/course_discovery/apps/api/serializers.py +++ b/course_discovery/apps/api/serializers.py @@ -1099,10 +1099,10 @@ class Meta(MinimalCourseRunSerializer.Meta): 'first_enrollable_paid_seat_price', 'has_ofac_restrictions', 'ofac_comment', 'enrollment_count', 'recent_enrollment_count', 'expected_program_type', 'expected_program_name', 'course_uuid', 'estimated_hours', 'content_language_search_facet_name', 'enterprise_subscription_inclusion', - 'transcript_languages_search_facet_names' + 'transcript_languages_search_facet_names', 'translation_languages' ) read_only_fields = ('enrollment_count', 'recent_enrollment_count', 'content_language_search_facet_name', - 'enterprise_subscription_inclusion') + 'enterprise_subscription_inclusion', 'translation_languages') def get_instructors(self, obj): # pylint: disable=unused-argument # This field is deprecated. Use the staff field. diff --git a/course_discovery/apps/api/tests/test_serializers.py b/course_discovery/apps/api/tests/test_serializers.py index 33abb93f33..2151123a04 100644 --- a/course_discovery/apps/api/tests/test_serializers.py +++ b/course_discovery/apps/api/tests/test_serializers.py @@ -712,7 +712,8 @@ def get_expected_data(cls, course_run, request): 'enterprise_subscription_inclusion': course_run.enterprise_subscription_inclusion, 'transcript_languages_search_facet_names': [ lang.get_search_facet_display() for lang in course_run.transcript_languages.all() - ] + ], + 'translation_languages': course_run.translation_languages, }) return expected diff --git a/course_discovery/apps/course_metadata/admin.py b/course_discovery/apps/course_metadata/admin.py index 5e25c1051b..0529400a8b 100644 --- a/course_discovery/apps/course_metadata/admin.py +++ b/course_discovery/apps/course_metadata/admin.py @@ -281,7 +281,7 @@ class CourseRunAdmin(SimpleHistoryAdmin): raw_id_fields = ('course', 'draft_version',) readonly_fields = [ 'enrollment_count', 'recent_enrollment_count', 'hidden', 'key', 'enterprise_subscription_inclusion', - 'variant_id', 'fixed_price_usd' + 'variant_id', 'fixed_price_usd', 'translation_languages' ] search_fields = ('uuid', 'key', 'title_override', 'course__title', 'slug', 'external_key', 'variant_id') save_error = False diff --git a/course_discovery/apps/course_metadata/algolia_models.py b/course_discovery/apps/course_metadata/algolia_models.py index 6945ce87a6..ce37f61814 100644 --- a/course_discovery/apps/course_metadata/algolia_models.py +++ b/course_discovery/apps/course_metadata/algolia_models.py @@ -82,7 +82,8 @@ def delegate_attributes(cls): search_fields = ['partner_names', 'partner_keys', 'product_title', 'product_source', 'primary_description', 'secondary_description', 'tertiary_description'] facet_fields = ['availability_level', 'subject_names', 'levels', 'active_languages', 'staff_slugs', - 'product_allowed_in', 'product_blocked_in', 'learning_type', 'learning_type_exp'] + 'product_allowed_in', 'product_blocked_in', 'learning_type', 'learning_type_exp', + 'product_translation_languages'] ranking_fields = ['availability_rank', 'product_recent_enrollment_count', 'promoted_in_spanish_index', 'product_value_per_click_usa', 'product_value_per_click_international', 'product_value_per_lead_usa', 'product_value_per_lead_international'] @@ -353,6 +354,12 @@ def product_min_effort(self): def product_max_effort(self): return getattr(self.advertised_course_run, 'max_effort', None) + @property + def product_translation_languages(self): + if self.advertised_course_run and self.advertised_course_run.translation_languages: + return self.advertised_course_run.translation_languages + return [] + @property def owners(self): return get_owners(self) @@ -533,6 +540,10 @@ def product_min_effort(self): def product_max_effort(self): return self.max_hours_effort_per_week + @property + def product_translation_languages(self): + return [] + @property def subject_names(self): if self.primary_subject_override: diff --git a/course_discovery/apps/course_metadata/index.py b/course_discovery/apps/course_metadata/index.py index b924f3123a..7617601665 100644 --- a/course_discovery/apps/course_metadata/index.py +++ b/course_discovery/apps/course_metadata/index.py @@ -80,7 +80,8 @@ class EnglishProductIndex(BaseProductIndex): ('active_languages', 'language'), ('product_type', 'product'), ('program_types', 'program_type'), ('staff_slugs', 'staff'), ('product_allowed_in', 'allowed_in'), ('product_blocked_in', 'blocked_in'), 'subscription_eligible', - 'subscription_prices', 'learning_type', 'learning_type_exp',) + 'subscription_prices', 'learning_type', 'learning_type_exp', + ('product_translation_languages', 'translation_languages')) ranking_fields = ('availability_rank', ('product_recent_enrollment_count', 'recent_enrollment_count'), ('product_value_per_click_usa', 'value_per_click_usa'), ('product_value_per_click_international', 'value_per_click_international'), @@ -116,7 +117,7 @@ class EnglishProductIndex(BaseProductIndex): 'partner', 'availability', 'subject', 'level', 'language', 'product', 'program_type', 'filterOnly(staff)', 'filterOnly(allowed_in)', 'filterOnly(blocked_in)', 'skills.skill', 'skills.category', 'skills.subcategory', 'tags', 'subscription_eligible', 'subscription_prices', - 'learning_type', 'learning_type_exp', + 'learning_type', 'learning_type_exp', 'translation_languages.code', 'translation_languages.label', ], 'customRanking': ['asc(availability_rank)', 'desc(recent_enrollment_count)'] } @@ -133,7 +134,8 @@ class SpanishProductIndex(BaseProductIndex): ('active_languages', 'language'), ('product_type', 'product'), ('program_types', 'program_type'), ('staff_slugs', 'staff'), ('product_allowed_in', 'allowed_in'), ('product_blocked_in', 'blocked_in'), 'subscription_eligible', - 'subscription_prices', 'learning_type', 'learning_type_exp',) + 'subscription_prices', 'learning_type', 'learning_type_exp', + ('product_translation_languages', 'translation_languages')) ranking_fields = ('availability_rank', ('product_recent_enrollment_count', 'recent_enrollment_count'), ('product_value_per_click_usa', 'value_per_click_usa'), ('product_value_per_click_international', 'value_per_click_international'), @@ -171,7 +173,8 @@ class SpanishProductIndex(BaseProductIndex): 'partner', 'availability', 'subject', 'level', 'language', 'product', 'program_type', 'filterOnly(staff)', 'filterOnly(allowed_in)', 'filterOnly(blocked_in)', 'skills.skill', 'skills.category', 'skills.subcategory', 'tags', 'subscription_eligible', - 'subscription_prices', 'learning_type', 'learning_type_exp', + 'subscription_prices', 'learning_type', 'learning_type_exp', 'translation_languages.code', + 'translation_languages.label', ], 'customRanking': ['desc(promoted_in_spanish_index)', 'asc(availability_rank)', 'desc(recent_enrollment_count)'] } diff --git a/course_discovery/apps/course_metadata/migrations/0345_courserun_translation_languages_and_more.py b/course_discovery/apps/course_metadata/migrations/0345_courserun_translation_languages_and_more.py new file mode 100644 index 0000000000..d670d18493 --- /dev/null +++ b/course_discovery/apps/course_metadata/migrations/0345_courserun_translation_languages_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.13 on 2024-08-28 04:13 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('course_metadata', '0344_courserun_fixed_price_usd_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='courserun', + name='translation_languages', + field=models.JSONField(blank=True, help_text='A JSON list detailing the available translations for this run. Each element in the list is a dictionary containing two keys: the language code and the language label. These entries represent the languages into which the run content can be translated.', null=True), + ), + migrations.AddField( + model_name='historicalcourserun', + name='translation_languages', + field=models.JSONField(blank=True, help_text='A JSON list detailing the available translations for this run. Each element in the list is a dictionary containing two keys: the language code and the language label. These entries represent the languages into which the run content can be translated.', null=True), + ), + ] diff --git a/course_discovery/apps/course_metadata/models.py b/course_discovery/apps/course_metadata/models.py index decaebf20a..4bddf9aa24 100644 --- a/course_discovery/apps/course_metadata/models.py +++ b/course_discovery/apps/course_metadata/models.py @@ -2157,6 +2157,14 @@ class CourseRun(ManageHistoryMixin, DraftModelMixin, CachedMixin, TimeStampedMod help_text=_('Estimated number of weeks needed to complete this course run.')) language = models.ForeignKey(LanguageTag, models.CASCADE, null=True, blank=True) transcript_languages = models.ManyToManyField(LanguageTag, blank=True, related_name='transcript_courses') + translation_languages = models.JSONField( + null=True, + blank=True, + help_text=_('A JSON list detailing the available translations for this run. ' + 'Each element in the list is a dictionary containing two keys: the language code ' + 'and the language label. These entries represent the languages into which the run ' + 'content can be translated.') + ) pacing_type = models.CharField(max_length=255, db_index=True, null=True, blank=True, choices=CourseRunPacing.choices) syllabus = models.ForeignKey(SyllabusItem, models.CASCADE, default=None, null=True, blank=True) diff --git a/course_discovery/apps/course_metadata/tests/factories.py b/course_discovery/apps/course_metadata/tests/factories.py index 2899186cee..76badebcaa 100644 --- a/course_discovery/apps/course_metadata/tests/factories.py +++ b/course_discovery/apps/course_metadata/tests/factories.py @@ -482,6 +482,7 @@ class CourseRunFactory(SalesforceRecordFactory): type = factory.SubFactory(CourseRunTypeFactory) variant_id = factory.LazyFunction(uuid4) fixed_price_usd = FuzzyDecimal(0.0, 650.0) + translation_languages = [{'code': 'fr', 'label': 'French'}] @factory.post_generation def staff(self, create, extracted, **kwargs): diff --git a/course_discovery/apps/course_metadata/tests/test_algolia_models.py b/course_discovery/apps/course_metadata/tests/test_algolia_models.py index 6a5b5691f7..b5a669c6e0 100644 --- a/course_discovery/apps/course_metadata/tests/test_algolia_models.py +++ b/course_discovery/apps/course_metadata/tests/test_algolia_models.py @@ -151,7 +151,7 @@ def create_course_with_basic_active_course_run(self, **kwargs): ) return course - def create_blocked_course_run(self, **kwargs): + def create_blocked_course(self, status=CourseRunStatus.Published, **kwargs): course = AlgoliaProxyCourseFactory(partner=self.__class__.edxPartner, product_source=SourceFactory(slug='blocked')) @@ -159,7 +159,7 @@ def create_blocked_course_run(self, **kwargs): course=course, start=self.YESTERDAY, end=self.YESTERDAY, - status=CourseRunStatus.Published, + status=status, **kwargs ) SeatFactory( @@ -442,13 +442,13 @@ def test_product_source_with_empty_source(self, model_factory, product_source): @override_settings(ALGOLIA_INDEX_EXCLUDED_SOURCES=[]) def test_product_source_excluded(self): - course = self.create_blocked_course_run() + course = self.create_blocked_course() course.authoring_organizations.add(OrganizationFactory()) assert course.should_index @override_settings(ALGOLIA_INDEX_EXCLUDED_SOURCES=['blocked']) def test_product_source_should_excluded(self): - course = self.create_blocked_course_run() + course = self.create_blocked_course() course.authoring_organizations.add(OrganizationFactory()) assert not course.should_index @@ -569,6 +569,14 @@ def test_learning_type_exp_non_open_course(self, course_type_slug, expected_resu course.type = CourseTypeFactory(slug=course_type_slug) assert course.learning_type_exp == [expected_result] + def test_course_translation_languages(self): + course = self.create_current_upgradeable_course() + assert course.product_translation_languages == [{'code': 'fr', 'label': 'French'}] + + def test_course_translation_languages__no_advertised_run(self): + course = self.create_blocked_course(status=CourseRunStatus.Unpublished) + assert course.product_translation_languages == [] + @ddt.ddt @pytest.mark.django_db @@ -908,3 +916,7 @@ def test_learning_type_exp(self, program_type_slug, learning_type): program_type = ProgramType.objects.get(slug=program_type_slug) program = AlgoliaProxyProgramFactory(partner=self.__class__.edxPartner, type=program_type) assert program.learning_type_exp == [learning_type] + + def test_program_translation_languages(self): + program = AlgoliaProxyProgramFactory(partner=self.__class__.edxPartner) + assert program.product_translation_languages == [] diff --git a/course_discovery/apps/course_metadata/tests/test_models.py b/course_discovery/apps/course_metadata/tests/test_models.py index 9a29dca11c..f268e9f1ea 100644 --- a/course_discovery/apps/course_metadata/tests/test_models.py +++ b/course_discovery/apps/course_metadata/tests/test_models.py @@ -966,6 +966,17 @@ def test_course_run_fixed_usd_price(self): ) assert course_run.fixed_price_usd is None + def test_course_run_translation_languages(self): + """ + Sanity checks for the translation_languages field + """ + DEFAULT_TRANSLATION_LANGUAGES = [{'code': 'fr', 'label': 'French'}] + course_run = factories.CourseRunFactory() + assert course_run.translation_languages == DEFAULT_TRANSLATION_LANGUAGES + + course_run = factories.CourseRunFactory(translation_languages=None) + assert course_run.translation_languages is None + @ddt.data('full_description_override', 'outcome_override', 'short_description_override') def test_html_fields_are_validated(self, field_name): # Happy path