diff --git a/lms/djangoapps/learner_home/serializers.py b/lms/djangoapps/learner_home/serializers.py index f7eed25d22e9..ddc012e2f96b 100644 --- a/lms/djangoapps/learner_home/serializers.py +++ b/lms/djangoapps/learner_home/serializers.py @@ -93,6 +93,7 @@ class CourseRunSerializer(serializers.Serializer): isStarted = serializers.SerializerMethodField() isArchived = serializers.SerializerMethodField() courseId = serializers.CharField(source="course_id") + courseUuid = serializers.UUIDField(source="course_overview.variant_id", allow_null=True) minPassingGrade = serializers.DecimalField( max_digits=5, decimal_places=2, source="course_overview.lowest_passing_grade" ) diff --git a/lms/djangoapps/learner_home/test_serializers.py b/lms/djangoapps/learner_home/test_serializers.py index 7b8268b42c76..b82da5c60a2b 100644 --- a/lms/djangoapps/learner_home/test_serializers.py +++ b/lms/djangoapps/learner_home/test_serializers.py @@ -89,6 +89,7 @@ def create_test_enrollment(self, course_mode=CourseMode.AUDIT): test_enrollment.course_overview.marketing_url = random_url() test_enrollment.course_overview.end = random_date() test_enrollment.course_overview.certificate_available_date = random_date() + test_enrollment.course_overview.variant_id = uuid4() return test_enrollment @@ -213,6 +214,20 @@ def test_with_data(self): for key in output_data: assert output_data[key] is not None + def test_missing_course_uuid(self): + # Given a course run + input_data = self.create_test_enrollment() + input_context = self.create_test_context(input_data.course.id) + + # ... where the catalog had nothing for this course + input_data.course_overview.variant_id = None + + # When I serialize + output_data = CourseRunSerializer(input_data, context=input_context).data + + # Then courseUuid is None, which is allowed + self.assertIsNone(output_data["courseUuid"]) + def test_missing_resume_url(self): # Given a course run input_data = self.create_test_enrollment() diff --git a/openedx/core/djangoapps/catalog/management/commands/sync_course_runs.py b/openedx/core/djangoapps/catalog/management/commands/sync_course_runs.py index a1bf31bd9577..1f8bac6549bf 100644 --- a/openedx/core/djangoapps/catalog/management/commands/sync_course_runs.py +++ b/openedx/core/djangoapps/catalog/management/commands/sync_course_runs.py @@ -25,6 +25,7 @@ class Command(BaseCommand): CourseRunField(catalog_name='marketing_url', course_overview_name='marketing_url'), CourseRunField(catalog_name='eligible_for_financial_aid', course_overview_name='eligible_for_financial_aid'), CourseRunField(catalog_name='content_language', course_overview_name='language'), + CourseRunField(catalog_name='variant_id', course_overview_name='variant_id'), ) def handle(self, *args, **options): @@ -51,7 +52,8 @@ def handle(self, *args, **options): is_course_metadata_updated = False for field in self.course_run_fields: catalog_value = course_run.get(field.catalog_name) - if getattr(course_overview, field.course_overview_name) != catalog_value: + current_value = getattr(course_overview, field.course_overview_name) + if str(current_value) != str(catalog_value): setattr(course_overview, field.course_overview_name, catalog_value) is_course_metadata_updated = True diff --git a/openedx/core/djangoapps/catalog/management/commands/tests/test_sync_course_runs.py b/openedx/core/djangoapps/catalog/management/commands/tests/test_sync_course_runs.py index 882488f6c615..1a20f60a8f7a 100644 --- a/openedx/core/djangoapps/catalog/management/commands/tests/test_sync_course_runs.py +++ b/openedx/core/djangoapps/catalog/management/commands/tests/test_sync_course_runs.py @@ -52,8 +52,9 @@ def test_course_run_sync(self, mock_catalog_course_runs): previous_course_overview_value = getattr(self.course_overview, course_overview_field_name) updated_course_overview_value = getattr(updated_course_overview, course_overview_field_name) - # course overview value matches catalog value - assert updated_course_overview_value == self.catalog_course_run.get(catalog_field_name) # pylint: disable=no-member, line-too-long + # course overview value matches catalog value (compared as strings, since e.g. + # variant_id round-trips as a uuid.UUID, not the raw string the catalog returned) + assert str(updated_course_overview_value) == str(self.catalog_course_run.get(catalog_field_name)) # pylint: disable=no-member, line-too-long # new value doesn't match old value assert updated_course_overview_value != previous_course_overview_value diff --git a/openedx/core/djangoapps/catalog/tests/factories.py b/openedx/core/djangoapps/catalog/tests/factories.py index 5c06f08d6e73..e1abd93d28c8 100644 --- a/openedx/core/djangoapps/catalog/tests/factories.py +++ b/openedx/core/djangoapps/catalog/tests/factories.py @@ -174,6 +174,7 @@ class CourseRunFactory(DictFactoryBase): content_language = 'en' max_effort = 4 weeks_to_complete = 10 + variant_id = factory.Faker('uuid4') class CourseFactory(DictFactoryBase): diff --git a/openedx/core/djangoapps/content/course_overviews/migrations/0030_courseoverview_variant_id.py b/openedx/core/djangoapps/content/course_overviews/migrations/0030_courseoverview_variant_id.py new file mode 100644 index 000000000000..29b32400ebb6 --- /dev/null +++ b/openedx/core/djangoapps/content/course_overviews/migrations/0030_courseoverview_variant_id.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.28 on 2026-09-08 14:25 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('course_overviews', '0029_alter_historicalcourseoverview_options'), + ] + + operations = [ + migrations.AddField( + model_name='courseoverview', + name='variant_id', + field=models.UUIDField(blank=True, null=True), + ), + migrations.AddField( + model_name='historicalcourseoverview', + name='variant_id', + field=models.UUIDField(blank=True, null=True), + ), + ] diff --git a/openedx/core/djangoapps/content/course_overviews/models.py b/openedx/core/djangoapps/content/course_overviews/models.py index a53c9e9b1d14..cfb086dfdbff 100644 --- a/openedx/core/djangoapps/content/course_overviews/models.py +++ b/openedx/core/djangoapps/content/course_overviews/models.py @@ -66,7 +66,7 @@ class Meta: app_label = 'course_overviews' # IMPORTANT: Bump this whenever you modify this model and/or add a migration. - VERSION = 19 + VERSION = 20 # Cache entry versioning. version = models.IntegerField() @@ -132,6 +132,10 @@ class Meta: marketing_url = models.TextField(null=True) eligible_for_financial_aid = models.BooleanField(default=True) + # Identifier linking this course run to a product variant in an external LOB system + # (i.e. ExecEd & Bootcamps, e.g. GetSmarter/Titan). Synced from discovery's CourseRun.variant_id. + variant_id = models.UUIDField(null=True, blank=True) + # Course highlight info, used to guide course update emails has_highlights = models.BooleanField(null=True, default=None) # if None, you have to look up the answer yourself