Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Anawaz/prod 4306 #4581

Closed
wants to merge 2 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions course_discovery/apps/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -1103,10 +1103,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', 'translation_languages'
'transcript_languages_search_facet_names', 'translation_languages', 'ai_languages',
)
read_only_fields = ('enrollment_count', 'recent_enrollment_count', 'content_language_search_facet_name',
'enterprise_subscription_inclusion', 'translation_languages')
'enterprise_subscription_inclusion', 'translation_languages', 'ai_languages')

def get_instructors(self, obj): # pylint: disable=unused-argument
# This field is deprecated. Use the staff field.
Expand Down
1 change: 1 addition & 0 deletions course_discovery/apps/api/tests/test_serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -783,6 +783,7 @@ def get_expected_data(cls, course_run, request):
lang.get_search_facet_display() for lang in course_run.transcript_languages.all()
],
'translation_languages': course_run.translation_languages,
'ai_languages': course_run.ai_languages,
})
return expected

Expand Down
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 @@ -230,3 +230,24 @@ def get_course_run_translations(self, course_run_id: str):
except RequestException as e:
logger.exception(f'Failed to fetch translation data for course run [{course_run_id}]: {e}')
return {}

def get_course_run_translations_and_transcriptions(self, course_run_id: str):
"""
Get translation and transcription 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 relevant information or an empty dict on error.
"""
resource = settings.LMS_API_URLS['translations_and_transcriptions']
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 data for course run [{course_run_id}]: {e}')
return {}
2 changes: 1 addition & 1 deletion course_discovery/apps/course_metadata/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -313,7 +313,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', 'translation_languages'
'variant_id', 'fixed_price_usd', 'translation_languages', 'ai_languages'
]
search_fields = ('uuid', 'key', 'title_override', 'course__title', 'slug', 'external_key', 'variant_id')
save_error = False
Expand Down
21 changes: 20 additions & 1 deletion course_discovery/apps/course_metadata/algolia_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ def delegate_attributes(cls):
'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_translation_languages']
'product_translation_languages', 'product_ai_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']
Expand Down Expand Up @@ -360,6 +360,18 @@ def product_translation_languages(self):
return self.advertised_course_run.translation_languages
return []

@property
def product_ai_languages(self):
if ai_langs:=(self.advertised_course_run and self.advertised_course_run.ai_languages):
return {
'translation_languages': [lang['label'] for lang in ai_langs['translation_languages']],
'transcription_languages': [lang['label'] for lang in ai_langs['transcription_languages']]
}
return {
'translation_languages': [],
'transcription_languages': []
}

@property
def owners(self):
return get_owners(self)
Expand Down Expand Up @@ -547,6 +559,13 @@ def product_max_effort(self):
def product_translation_languages(self):
return []

@property
def product_ai_languages(self):
return {
'translation_languages': [],
'transcription_languages': []
}

@property
def subject_names(self):
if self.primary_subject_override:
Expand Down
9 changes: 6 additions & 3 deletions course_discovery/apps/course_metadata/index.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,8 @@ class EnglishProductIndex(BaseProductIndex):
('staff_slugs', 'staff'), ('product_allowed_in', 'allowed_in'),
('product_blocked_in', 'blocked_in'), 'subscription_eligible',
'subscription_prices', 'learning_type', 'learning_type_exp',
('product_translation_languages', 'translation_languages'))
('product_translation_languages', 'translation_languages'),
('product_ai_languages', 'ai_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'),
Expand Down Expand Up @@ -118,6 +119,7 @@ class EnglishProductIndex(BaseProductIndex):
'filterOnly(staff)', 'filterOnly(allowed_in)', 'filterOnly(blocked_in)', 'skills.skill',
'skills.category', 'skills.subcategory', 'tags', 'subscription_eligible', 'subscription_prices',
'learning_type', 'learning_type_exp', 'translation_languages.code', 'translation_languages.label',
'ai_languages.translation_languages', 'ai_languages.transcription_languages'
],
'customRanking': ['asc(availability_rank)', 'desc(recent_enrollment_count)']
}
Expand All @@ -135,7 +137,8 @@ class SpanishProductIndex(BaseProductIndex):
('staff_slugs', 'staff'), ('product_allowed_in', 'allowed_in'),
('product_blocked_in', 'blocked_in'), 'subscription_eligible',
'subscription_prices', 'learning_type', 'learning_type_exp',
('product_translation_languages', 'translation_languages'))
('product_translation_languages', 'translation_languages'),
('product_ai_languages', 'ai_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'),
Expand Down Expand Up @@ -174,7 +177,7 @@ class SpanishProductIndex(BaseProductIndex):
'filterOnly(staff)', 'filterOnly(allowed_in)', 'filterOnly(blocked_in)',
'skills.skill', 'skills.category', 'skills.subcategory', 'tags', 'subscription_eligible',
'subscription_prices', 'learning_type', 'learning_type_exp', 'translation_languages.code',
'translation_languages.label',
'translation_languages.label', 'ai_languages.translation_languages', 'ai_languages.transcription_languages'
],
'customRanking': ['desc(promoted_in_spanish_index)', 'asc(availability_rank)', 'desc(recent_enrollment_count)']
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
"""
Unit tests for the `update_course_ai_languages` 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."""

AI_LANGS_DATA = {
'available_translation_languages': [
{'code': 'fr', 'label': 'French'},
{'code': 'es', 'label': 'Spanish'}
],
'feature_enabled': True
}

AI_LANGS_DATA_WITH_TRANSCRIPTIONS = {
**AI_LANGS_DATA,
'transcription_languages': ['en', 'gf']
}

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')
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
"""
Management command to fetch translation and transcription 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 and Transcriptions 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_languages --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'] and options['marketable']:
course_runs = course_runs.marketable().union(course_runs.active())
elif options['active']:
course_runs = course_runs.active()
elif options['marketable']:
course_runs = course_runs.marketable()

for course_run in course_runs:
try:
translation_data = lms_api_client.get_course_run_translations_and_transcriptions(course_run.key)
available_translation_languages = (
translation_data.get('available_translation_languages', [])
if translation_data.get('feature_enabled', False)
else []
)
available_transcription_languages = translation_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 shortly
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.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 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}')
Loading
Loading