diff --git a/course_discovery/apps/core/api_client/lms.py b/course_discovery/apps/core/api_client/lms.py index 74f92f71e25..916e0cf6d3d 100644 --- a/course_discovery/apps/core/api_client/lms.py +++ b/course_discovery/apps/core/api_client/lms.py @@ -209,3 +209,24 @@ 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): + """ + Get translation information for a given course run. + + Args: + course_run_id (str): The course run ID to fetch translation information for. + + Returns: + dict: A dictionary containing the translation information or an empty dict on error. + """ + resource = settings.LMS_API_URLS['translations'] + resource_url = urljoin(self.lms_url, resource) + + try: + response = self.client.get(resource_url, params={'course_id': course_run_id}) + 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}') + return {} diff --git a/course_discovery/apps/core/tests/test_api_clients.py b/course_discovery/apps/core/tests/test_api_clients.py index f044bb75e51..14b8c480343 100644 --- a/course_discovery/apps/core/tests/test_api_clients.py +++ b/course_discovery/apps/core/tests/test_api_clients.py @@ -237,3 +237,45 @@ def test_get_blocks_data_cache_hit(self): assert self.lms.get_blocks_data(self.block_id) == data['blocks'] assert self.lms.get_blocks_data(self.block_id) == data['blocks'] assert len(responses.calls) == 1 + + @responses.activate + def test_get_course_run_translations(self): + """ + Verify that `get_course_run_translations` returns correct translation data. + """ + course_id = 'course-v1:edX+DemoX+Demo_Course+type@course+block@course' + translation_data = { + "en": {"title": "Course Title", "language": "English"}, + "fr": {"title": "Titre du cours", "language": "French"} + } + resource = settings.LMS_API_URLS['translations'] + resource_url = urljoin(self.partner.lms_url, resource) + + responses.add( + responses.GET, + resource_url, + json=translation_data, + status=200 + ) + + result = self.lms.get_course_run_translations(course_id) + assert result == translation_data + + @responses.activate + def test_get_course_run_translations_with_error(self): + """ + Verify that get_course_run_translations returns an empty dictionary when there's an error. + """ + course_id = 'course-v1:edX+DemoX+Demo_Course+type@course+block@course' + resource = settings.LMS_API_URLS['translations'] + resource_url = urljoin(self.partner.lms_url, resource) + + responses.add( + responses.GET, + resource_url, + status=500 # Simulating an internal server error + ) + + result = self.lms.get_course_run_translations(course_id) + assert result == {} + assert 'Failed to fetch translation data for course [%s]' % course_id in self.log_messages['error'][0] diff --git a/course_discovery/apps/course_metadata/management/commands/tests/test_update_translations.py b/course_discovery/apps/course_metadata/management/commands/tests/test_update_translations.py new file mode 100644 index 00000000000..4a2e21a28fe --- /dev/null +++ b/course_discovery/apps/course_metadata/management/commands/tests/test_update_translations.py @@ -0,0 +1,84 @@ +""" +Unit tests for the `update_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 UpdateTranslationsTests(TestCase): + """Test Suite for the update_translations management command.""" + + 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 = { + 'available_translation_languages': ['fr', 'es'], + 'feature_enabled': True + } + + call_command('update_translations', partner=self.partner.name) + + course_run = CourseRun.objects.get(id=self.course_run.id) + self.assertEqual(course_run.translation_languages, ['fr', 'es']) + + 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 = {} + + call_command('update_translations', partner=self.partner.name) + + course_run = CourseRun.objects.get(id=self.course_run.id) + self.assertEqual(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 = { + 'available_translation_languages': ['fr'], + 'feature_enabled': True + } + + active_course_run = CourseRunFactory(end=now() + datetime.timedelta(days=10)) + call_command('update_translations', partner=self.partner.name, active=True) + + active_course_run.refresh_from_db() + self.assertEqual(active_course_run.translation_languages, ['fr']) + + 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 = { + 'available_translation_languages': ['es'], + 'feature_enabled': True + } + + 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_translations', partner=self.partner.name, marketable=True) + + marketable_course_run.refresh_from_db() + self.assertEqual(marketable_course_run.translation_languages, ['es']) + + 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_translations', partner='nonexistent-partner') diff --git a/course_discovery/apps/course_metadata/management/commands/update_translations.py b/course_discovery/apps/course_metadata/management/commands/update_translations.py new file mode 100644 index 00000000000..5f97639387b --- /dev/null +++ b/course_discovery/apps/course_metadata/management/commands/update_translations.py @@ -0,0 +1,74 @@ +""" +Management command to fetch translation information from the LMS and update the CourseRun model. +""" + +import logging + +from django.conf import settings +from django.core.management.base import BaseCommand, CommandError + +from course_discovery.apps.core.api_client.lms import LMSAPIClient +from course_discovery.apps.course_metadata.models import CourseRun, Partner + +logger = logging.getLogger(__name__) + + +class Command(BaseCommand): + help = 'Fetches Content AI Translations metadata from the LMS and updates the CourseRun model in Discovery.' + + def add_arguments(self, parser): + parser.add_argument( + '--partner', + type=str, + default=settings.DEFAULT_PARTNER_ID, + help='Specify the partner name or ID to fetch translations for. ' + 'Defaults to the partner configured in settings.DEFAULT_PARTNER_ID.', + ) + parser.add_argument( + '--active', + action='store_true', + default=False, + help='Only update translations for active course runs. Defaults to False.', + ) + parser.add_argument( + '--marketable', + action='store_true', + default=False, + help='Only update translations for marketable course runs. Defaults to False.', + ) + + def handle(self, *args, **options): + """ + Example usage: ./manage.py update_translations --partner=edx --active --marketable + """ + partner_identifier = options.get('partner') + partner = Partner.objects.filter(name__iexact=partner_identifier).first() + + if not partner: + raise CommandError('No partner object found. Ensure that the Partner data is correctly configured.') + + lms_api_client = LMSAPIClient(partner) + + course_runs = CourseRun.objects.all() + + if options['active']: + course_runs = course_runs.active() + + if options['marketable']: + course_runs = course_runs.marketable() + + 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) + else [] + ) + course_run.draft_version.translation_languages = course_run.translation_languages + course_run.save() + + logger.info(f'Updated translations for {course_run.key}') + 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 6321638ddd4..fd45c3244ab 100644 --- a/course_discovery/settings/base.py +++ b/course_discovery/settings/base.py @@ -751,6 +751,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/', } # Map defining the required data fields against courses types and course's product source.