From b191d425cacd1ada7a5e43514fdd4d8dd2b1ce57 Mon Sep 17 00:00:00 2001 From: Ali Nawaz Date: Tue, 25 Feb 2025 11:57:01 +0500 Subject: [PATCH] chore: mgmt command changes --- course_discovery/apps/core/api_client/lms.py | 12 +- .../apps/core/tests/test_api_clients.py | 34 ++-- .../tests/test_update_course_ai_languages.py | 167 ++++++++++++++++++ .../test_update_course_ai_translations.py | 167 ------------------ ...tions.py => update_course_ai_languages.py} | 37 ++-- course_discovery/settings/base.py | 2 +- 6 files changed, 219 insertions(+), 200 deletions(-) create mode 100644 course_discovery/apps/course_metadata/management/commands/tests/test_update_course_ai_languages.py delete mode 100644 course_discovery/apps/course_metadata/management/commands/tests/test_update_course_ai_translations.py rename course_discovery/apps/course_metadata/management/commands/{update_course_ai_translations.py => update_course_ai_languages.py} (56%) diff --git a/course_discovery/apps/core/api_client/lms.py b/course_discovery/apps/core/api_client/lms.py index 916e0cf6d3..4a7b99e1c7 100644 --- a/course_discovery/apps/core/api_client/lms.py +++ b/course_discovery/apps/core/api_client/lms.py @@ -210,17 +210,17 @@ def get_blocks_metadata(self, block_id: str, **kwargs): cache_key = get_cache_key(block_id=block_id, resource=resource) return self._get_blocks_data(block_id, cache_key, query_parameters, resource) - def get_course_run_translations(self, course_run_id: str): + def get_course_run_translations_and_transcriptions(self, course_run_id: str): """ - Get translation information for a given course run. + Get translation and transcription information for a given course run. Args: - course_run_id (str): The course run ID to fetch translation information for. + course_run_id (str): The course run ID to fetch information for. Returns: - dict: A dictionary containing the translation information or an empty dict on error. + dict: A dictionary containing the relevant information or an empty dict on error. """ - resource = settings.LMS_API_URLS['translations'] + resource = settings.LMS_API_URLS['translations_and_transcriptions'] resource_url = urljoin(self.lms_url, resource) try: @@ -228,5 +228,5 @@ def get_course_run_translations(self, course_run_id: str): response.raise_for_status() return response.json() except RequestException as e: - logger.exception(f'Failed to fetch translation data for course run [{course_run_id}]: {e}') + logger.exception(f'Failed to fetch data for course run [{course_run_id}]: {e}') return {} diff --git a/course_discovery/apps/core/tests/test_api_clients.py b/course_discovery/apps/core/tests/test_api_clients.py index 738b3d6868..671bffe587 100644 --- a/course_discovery/apps/core/tests/test_api_clients.py +++ b/course_discovery/apps/core/tests/test_api_clients.py @@ -237,35 +237,43 @@ def test_get_blocks_data_cache_hit(self): assert len(responses.calls) == 1 @responses.activate - def test_get_course_run_translations(self): + def test_get_course_run_translations_and_transcriptions(self): """ - Verify that `get_course_run_translations` returns correct translation data. + Verify that `get_course_run_translations_and_transcriptions` returns correct data. """ course_run_id = 'course-v1:edX+DemoX+Demo_Course' - translation_data = { - "en": {"title": "Course Title", "language": "English"}, - "fr": {"title": "Titre du cours", "language": "French"} + response_data = { + "available_translation_languages": [ + { + "code": "ar", + "enabled": True, + "label": "Arabic" + } + ], + "feature_enabled": True, + "feature_available": False, + "transcription_languages": ["en", "fr"] } - resource = settings.LMS_API_URLS['translations'] + resource = settings.LMS_API_URLS['translations_and_transcriptions'] resource_url = urljoin(self.partner.lms_url, resource) responses.add( responses.GET, resource_url, - json=translation_data, + json=response_data, status=200 ) - result = self.lms.get_course_run_translations(course_run_id) + result = self.lms.get_course_run_translations_and_transcriptions(course_run_id) assert result == translation_data @responses.activate - def test_get_course_run_translations_with_error(self): + def test_get_course_run_translations_and_transcriptions_with_error(self): """ - Verify that get_course_run_translations returns an empty dictionary when there's an error. + Verify that `get_course_run_translations_and_transcriptions` returns an empty dictionary when there's an error. """ course_run_id = 'course-v1:edX+DemoX+Demo_Course' - resource = settings.LMS_API_URLS['translations'] + resource = settings.LMS_API_URLS['translations_and_transcriptions'] resource_url = urljoin(self.partner.lms_url, resource) responses.add( @@ -274,6 +282,6 @@ def test_get_course_run_translations_with_error(self): status=500 ) - result = self.lms.get_course_run_translations(course_run_id) + result = self.lms.get_course_run_translations_and_transcriptions(course_run_id) assert result == {} - assert 'Failed to fetch translation data for course run [%s]' % course_run_id in self.log_messages['error'][0] + assert 'Failed to fetch data for course run [%s]' % course_run_id in self.log_messages['error'][0] diff --git a/course_discovery/apps/course_metadata/management/commands/tests/test_update_course_ai_languages.py b/course_discovery/apps/course_metadata/management/commands/tests/test_update_course_ai_languages.py new file mode 100644 index 0000000000..ea3955c159 --- /dev/null +++ b/course_discovery/apps/course_metadata/management/commands/tests/test_update_course_ai_languages.py @@ -0,0 +1,167 @@ +""" +Unit tests for the `update_course_ai_languages` management command. +""" +import datetime +from unittest.mock import patch + +import ddt +from django.core.management import CommandError, call_command +from django.test import TestCase +from django.utils.timezone import now + +from course_discovery.apps.course_metadata.models import CourseRun, CourseRunType +from course_discovery.apps.course_metadata.tests.factories import CourseRunFactory, PartnerFactory, SeatFactory + + +@ddt.ddt +@patch('course_discovery.apps.core.api_client.lms.LMSAPIClient.get_course_run_translations_and_transcriptions') +class UpdateCourseAiLanguagesTests(TestCase): + """Test Suite for the update_course_ai_languages management command.""" + + AI_LANGUAGES_DATA = { + 'available_translation_languages': [ + {'code': 'fr', 'label': 'French'}, + {'code': 'es', 'label': 'Spanish'} + ], + 'feature_enabled': True, + } + + AI_LANGUAGES_DATA_WITH_TRANSCRIPTIONS = { + **AI_LANGUAGES_DATA, + 'transcription_languages': ['en', 'fr'] + } + + def setUp(self): + self.partner = PartnerFactory() + self.course_run = CourseRunFactory() + + def assert_ai_langs(self, run, data): + self.assertListEqual( + run.ai_languages['translation_languages'], + data['available_translation_languages'] + ) + self.assertListEqual( + run.ai_languages['transcription_languages'], + data.get('transcription_languages', []) + ) + + + @ddt.data(AI_LANGUAGES_DATA, AI_LANGUAGES_DATA_WITH_TRANSCRIPTIONS) + def test_update_ai_languages(self, mock_data, mock_get_translations_and_transcriptions): + """Test the command with a valid course run and response data.""" + mock_get_translations_and_transcriptions.return_value = mock_data + + call_command('update_course_ai_languages', partner=self.partner.name) + + course_run = CourseRun.objects.get(id=self.course_run.id) + self.assert_ai_langs(course_run, mock_data) + + @ddt.data(AI_LANGUAGES_DATA, AI_LANGUAGES_DATA_WITH_TRANSCRIPTIONS) + def test_update_ai_languages_draft(self, mock_data, mock_get_translations_and_transcriptions): + """ + Test the command with both draft and non-draft course runs, ensuring that the both draft and non-draft + course runs are updated appropriately. + """ + mock_get_translations_and_transcriptions.return_value = mock_data + draft_course_run = CourseRunFactory( + draft=True, end=now() + datetime.timedelta(days=10) + ) + course_run = CourseRunFactory(draft=False, draft_version_id=draft_course_run.id) + + call_command('update_course_ai_languages', partner=self.partner.name) + + course_run.refresh_from_db() + self.assert_ai_langs(course_run, mock_data) + + draft_course_run.refresh_from_db() + self.assert_ai_langs(draft_course_run, mock_data) + + + @ddt.data(AI_LANGUAGES_DATA, AI_LANGUAGES_DATA_WITH_TRANSCRIPTIONS) + def test_update_ai_languages_no_translations(self, mock_data, mock_get_translations_and_transcriptions): + """Test the command when no translations are returned for a course run.""" + mock_get_translations_and_transcriptions.return_value = { + **mock_data, + 'available_translation_languages': [], + } + + call_command('update_course_ai_languages', partner=self.partner.name) + + course_run = CourseRun.objects.get(id=self.course_run.id) + self.assertListEqual(course_run.ai_languages['translation_languages'], []) + + @ddt.data(AI_LANGUAGES_DATA, AI_LANGUAGES_DATA_WITH_TRANSCRIPTIONS) + def test_command_with_active_flag(self, mock_data, mock_get_translations_and_transcriptions): + """Test the command with the active flag filtering active course runs.""" + mock_get_translations_and_transcriptions.return_value = mock_data + + active_course_run = CourseRunFactory(end=now() + datetime.timedelta(days=10), ai_languages=None) + non_active_course_run = CourseRunFactory(end=now() - datetime.timedelta(days=10), ai_languages=None) + + call_command('update_course_ai_languages', partner=self.partner.name, active=True) + + active_course_run.refresh_from_db() + non_active_course_run.refresh_from_db() + + self.assert_ai_langs(active_course_run, mock_data) + assert non_active_course_run.ai_languages is None + + @ddt.data(AI_LANGUAGES_DATA, AI_LANGUAGES_DATA_WITH_TRANSCRIPTIONS) + def test_command_with_marketable_flag(self, mock_data, mock_get_translations_and_transcriptions): + """Test the command with the marketable flag filtering marketable course runs.""" + mock_get_translations_and_transcriptions.return_value = mock_data + + verified_and_audit_type = CourseRunType.objects.get(slug='verified-audit') + verified_and_audit_type.is_marketable = True + verified_and_audit_type.save() + + marketable_course_run = CourseRunFactory( + status='published', + slug='test-course-run', + type=verified_and_audit_type, + ai_languages=None + ) + seat = SeatFactory(course_run=marketable_course_run) + marketable_course_run.seats.add(seat) + + call_command('update_course_ai_languages', partner=self.partner.name, marketable=True) + + marketable_course_run.refresh_from_db() + self.assert_ai_langs(marketable_course_run, mock_data) + + @ddt.data(AI_LANGUAGES_DATA, AI_LANGUAGES_DATA_WITH_TRANSCRIPTIONS) + def test_command_with_marketable_and_active_flag(self, mock_data, mock_get_translations_and_transcriptions): + """Test the command with the marketable and active flag filtering both marketable and active course runs.""" + mock_get_translations_and_transcriptions.return_value = mock_data + + non_active_non_marketable_course_run = CourseRunFactory( + end=now() - datetime.timedelta(days=10), ai_languages=None + ) + active_non_marketable_course_run = CourseRunFactory( + end=now() + datetime.timedelta(days=10), ai_languages=None + ) + + verified_and_audit_type = CourseRunType.objects.get(slug='verified-audit') + verified_and_audit_type.is_marketable = True + verified_and_audit_type.save() + + marketable_non_active_course_run = CourseRunFactory( + status='published', + slug='test-course-run', + type=verified_and_audit_type, + end=now() - datetime.timedelta(days=10), ai_languages=None + ) + seat = SeatFactory(course_run=marketable_non_active_course_run) + marketable_non_active_course_run.seats.add(seat) + + call_command('update_course_ai_languages', partner=self.partner.name, marketable=True, active=True) + + marketable_non_active_course_run.refresh_from_db() + self.assert_ai_langs(marketable_non_active_course_run, mock_data) + self.assert_ai_langs(active_non_marketable_course_run, mock_data) + assert non_active_non_marketable_course_run.ai_languages is None + + def test_command_no_partner(self, _): + """Test the command raises an error if no valid partner is found.""" + with self.assertRaises(CommandError): + call_command('update_course_ai_languages', partner='nonexistent-partner') diff --git a/course_discovery/apps/course_metadata/management/commands/tests/test_update_course_ai_translations.py b/course_discovery/apps/course_metadata/management/commands/tests/test_update_course_ai_translations.py deleted file mode 100644 index 55e50439ce..0000000000 --- a/course_discovery/apps/course_metadata/management/commands/tests/test_update_course_ai_translations.py +++ /dev/null @@ -1,167 +0,0 @@ -""" -Unit tests for the `update_course_ai_translations` management command. -""" -import datetime -from unittest.mock import patch - -from django.core.management import CommandError, call_command -from django.test import TestCase -from django.utils.timezone import now - -from course_discovery.apps.course_metadata.models import CourseRun, CourseRunType -from course_discovery.apps.course_metadata.tests.factories import CourseRunFactory, PartnerFactory, SeatFactory - - -@patch('course_discovery.apps.core.api_client.lms.LMSAPIClient.get_course_run_translations') -class UpdateCourseAiTranslationsTests(TestCase): - """Test Suite for the update_course_ai_translations management command.""" - - TRANSLATION_DATA = { - 'available_translation_languages': [ - {'code': 'fr', 'label': 'French'}, - {'code': 'es', 'label': 'Spanish'} - ], - 'feature_enabled': True - } - - def setUp(self): - self.partner = PartnerFactory() - self.course_run = CourseRunFactory() - - def test_update_course_run_translations(self, mock_get_translations): - """Test the command with a valid course run and translation data.""" - mock_get_translations.return_value = self.TRANSLATION_DATA - - call_command('update_course_ai_translations', partner=self.partner.name) - - course_run = CourseRun.objects.get(id=self.course_run.id) - self.assertListEqual( - course_run.translation_languages, - self.TRANSLATION_DATA['available_translation_languages'] - ) - - def test_update_course_run_translations_draft(self, mock_get_translations): - """ - Test the command with both draft and non-draft course runs, ensuring that the both draft and non-draft - course runs are updated with the available translation languages. - """ - mock_get_translations.return_value = self.TRANSLATION_DATA - draft_course_run = CourseRunFactory( - draft=True, end=now() + datetime.timedelta(days=10) - ) - course_run = CourseRunFactory(draft=False, draft_version_id=draft_course_run.id) - - call_command("update_course_ai_translations", partner=self.partner.name) - - course_run.refresh_from_db() - self.assertListEqual( - course_run.translation_languages, self.TRANSLATION_DATA["available_translation_languages"], - ) - - draft_course_run.refresh_from_db() - self.assertListEqual( - draft_course_run.translation_languages, self.TRANSLATION_DATA["available_translation_languages"], - ) - - def test_command_with_no_translations(self, mock_get_translations): - """Test the command when no translations are returned for a course run.""" - mock_get_translations.return_value = { - **self.TRANSLATION_DATA, - 'available_translation_languages': [], - 'feature_enabled': False - } - - call_command('update_course_ai_translations', partner=self.partner.name) - - course_run = CourseRun.objects.get(id=self.course_run.id) - self.assertListEqual(course_run.translation_languages, []) - - def test_command_with_active_flag(self, mock_get_translations): - """Test the command with the active flag filtering active course runs.""" - mock_get_translations.return_value = { - **self.TRANSLATION_DATA, - 'available_translation_languages': [{'code': 'fr', 'label': 'French'}] - } - - active_course_run = CourseRunFactory(end=now() + datetime.timedelta(days=10)) - non_active_course_run = CourseRunFactory(end=now() - datetime.timedelta(days=10), translation_languages=[]) - - call_command('update_course_ai_translations', partner=self.partner.name, active=True) - - active_course_run.refresh_from_db() - non_active_course_run.refresh_from_db() - - self.assertListEqual( - active_course_run.translation_languages, - [{'code': 'fr', 'label': 'French'}] - ) - self.assertListEqual(non_active_course_run.translation_languages, []) - - def test_command_with_marketable_flag(self, mock_get_translations): - """Test the command with the marketable flag filtering marketable course runs.""" - mock_get_translations.return_value = { - **self.TRANSLATION_DATA, - 'available_translation_languages': [{'code': 'es', 'label': 'Spanish'}] - } - - verified_and_audit_type = CourseRunType.objects.get(slug='verified-audit') - verified_and_audit_type.is_marketable = True - verified_and_audit_type.save() - - marketable_course_run = CourseRunFactory( - status='published', - slug='test-course-run', - type=verified_and_audit_type - ) - seat = SeatFactory(course_run=marketable_course_run) - marketable_course_run.seats.add(seat) - - call_command('update_course_ai_translations', partner=self.partner.name, marketable=True) - - marketable_course_run.refresh_from_db() - self.assertListEqual( - marketable_course_run.translation_languages, - [{'code': 'es', 'label': 'Spanish'}] - ) - - def test_command_with_marketable_and_active_flag(self, mock_get_translations): - """Test the command with the marketable and active flag filtering both marketable and active course runs.""" - mock_get_translations.return_value = { - **self.TRANSLATION_DATA, - 'available_translation_languages': [{'code': 'fr', 'label': 'French'}] - } - - non_active_non_marketable_course_run = CourseRunFactory( - end=now() - datetime.timedelta(days=10), translation_languages=[]) - active_non_marketable_course_run = CourseRunFactory(end=now() + datetime.timedelta(days=10)) - - verified_and_audit_type = CourseRunType.objects.get(slug='verified-audit') - verified_and_audit_type.is_marketable = True - verified_and_audit_type.save() - - marketable_non_active_course_run = CourseRunFactory( - status='published', - slug='test-course-run', - type=verified_and_audit_type, - end=now() - datetime.timedelta(days=10), translation_languages=[] - ) - seat = SeatFactory(course_run=marketable_non_active_course_run) - marketable_non_active_course_run.seats.add(seat) - - call_command('update_course_ai_translations', partner=self.partner.name, marketable=True, active=True) - - marketable_non_active_course_run.refresh_from_db() - self.assertListEqual( - marketable_non_active_course_run.translation_languages, - [{'code': 'fr', 'label': 'French'}] - ) - self.assertListEqual( - active_non_marketable_course_run.translation_languages, - [{'code': 'fr', 'label': 'French'}] - ) - self.assertListEqual(non_active_non_marketable_course_run.translation_languages, []) - - def test_command_no_partner(self, _): - """Test the command raises an error if no valid partner is found.""" - with self.assertRaises(CommandError): - call_command('update_course_ai_translations', partner='nonexistent-partner') diff --git a/course_discovery/apps/course_metadata/management/commands/update_course_ai_translations.py b/course_discovery/apps/course_metadata/management/commands/update_course_ai_languages.py similarity index 56% rename from course_discovery/apps/course_metadata/management/commands/update_course_ai_translations.py rename to course_discovery/apps/course_metadata/management/commands/update_course_ai_languages.py index 8cbd5826b0..75a8d6bd3f 100644 --- a/course_discovery/apps/course_metadata/management/commands/update_course_ai_translations.py +++ b/course_discovery/apps/course_metadata/management/commands/update_course_ai_languages.py @@ -1,5 +1,5 @@ """ -Management command to fetch translation information from the LMS and update the CourseRun model. +Management command to fetch translation and transcription information from the LMS and update the CourseRun model. """ import logging @@ -14,7 +14,7 @@ class Command(BaseCommand): - help = 'Fetches Content AI Translations metadata from the LMS and updates the CourseRun model in Discovery.' + help = 'Fetches Content AI Translations and Transcriptions metadata from the LMS and updates the CourseRun model in Discovery.' def add_arguments(self, parser): parser.add_argument( @@ -39,7 +39,7 @@ def add_arguments(self, parser): def handle(self, *args, **options): """ - Example usage: ./manage.py update_course_ai_translations --partner=edx --active --marketable + Example usage: ./manage.py update_course_ai_languages --partner=edx --active --marketable """ partner_identifier = options.get('partner') partner = Partner.objects.filter(name__iexact=partner_identifier).first() @@ -60,20 +60,31 @@ def handle(self, *args, **options): for course_run in course_runs: try: - translation_data = lms_api_client.get_course_run_translations(course_run.key) - - course_run.translation_languages = ( - translation_data.get('available_translation_languages', []) - if translation_data.get('feature_enabled', False) + ai_languages_data = lms_api_client.get_course_run_translations_and_transcriptions(course_run.key) + available_translation_languages = ( + ai_languages_data.get('available_translation_languages', []) + if ai_languages_data.get('feature_enabled', False) else [] ) - course_run.save(update_fields=["translation_languages"]) + available_transcription_languages = ai_languages_data.get('transcription_languages', []) + + # Remove any keys other than `code` and `label` + available_translation_languages = [{'code': lang['code'], 'label': lang['label']} for lang in available_translation_languages] + + # Add the labels for the codes. Currently we set the code as the label. We will be fixing this in a follow-up PR + available_transcription_languages = [{'code': lang, 'label': lang} for lang in available_transcription_languages] + + course_run.ai_languages = { + "translation_languages": available_translation_languages, + "transcription_languages": available_transcription_languages + } + course_run.save(update_fields=["ai_languages"]) if course_run.draft_version: - course_run.draft_version.translation_languages = course_run.translation_languages - course_run.draft_version.save(update_fields=["translation_languages"]) - logger.info(f'Updated translations for {course_run.key} (both draft and non-draft versions)') + course_run.draft_version.ai_languages = course_run.ai_languages + course_run.draft_version.save(update_fields=["ai_languages"]) + logger.info(f'Updated ai languages for {course_run.key} (both draft and non-draft versions)') else: - logger.info(f'Updated translations for {course_run.key} (non-draft version only)') + logger.info(f'Updated ai languages for {course_run.key} (non-draft version only)') except Exception as e: # pylint: disable=broad-except logger.error(f'Error processing {course_run.key}: {e}') diff --git a/course_discovery/settings/base.py b/course_discovery/settings/base.py index 143f76205a..63e3e918ee 100644 --- a/course_discovery/settings/base.py +++ b/course_discovery/settings/base.py @@ -753,7 +753,7 @@ 'api_access_request': 'api-admin/api/v1/api_access_request/', 'blocks': 'api/courses/v1/blocks/', 'block_metadata': 'api/courses/v1/block_metadata/', - 'translations': 'api/translatable_xblocks/config/', + 'translations_and_transcriptions': 'api/translatable_xblocks/config/', } # Map defining the required data fields against courses types and course's product source.