Skip to content

Commit

Permalink
feat: add sync job to populate translation info (#4415)
Browse files Browse the repository at this point in the history
  • Loading branch information
Ali-D-Akbar committed Sep 3, 2024
1 parent 193a187 commit 249cc1b
Show file tree
Hide file tree
Showing 5 changed files with 249 additions and 2 deletions.
21 changes: 21 additions & 0 deletions course_discovery/apps/core/api_client/lms.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 {}
44 changes: 42 additions & 2 deletions course_discovery/apps/core/tests/test_api_clients.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,8 +87,6 @@ def test_get_api_access_request_with_invalid_response(self):
Verify that `get_api_access_request` returns None when api_access_request
returns an invalid response.
"""
# API response without proper paginated structure.
# Following is an invalid response.
sample_invalid_response = {
'id': 1,
'created': '2017-09-25T08:37:05.872566Z',
Expand Down Expand Up @@ -237,3 +235,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_run_id = 'course-v1:edX+DemoX+Demo_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_run_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_run_id = 'course-v1:edX+DemoX+Demo_Course'
resource = settings.LMS_API_URLS['translations']
resource_url = urljoin(self.partner.lms_url, resource)

responses.add(
responses.GET,
resource_url,
status=500
)

result = self.lms.get_course_run_translations(course_run_id)
assert result == {}
assert 'Failed to fetch translation data for course run [%s]' % course_run_id in self.log_messages['error'][0]
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
"""
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_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_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')
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
"""
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_course_ai_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.save()

if course_run.draft_version:
course_run.draft_version.translation_languages = course_run.translation_languages
course_run.draft_version.save()
logger.info(f'Updated translations for {course_run.key} (both draft and non-draft versions)')
else:
logger.info(f'Updated translations 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}')
1 change: 1 addition & 0 deletions course_discovery/settings/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down

0 comments on commit 249cc1b

Please sign in to comment.