From 97e1631eef118fdd20c16cfdb98c67fdb8956121 Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Wed, 25 Feb 2026 09:57:09 -0800 Subject: [PATCH 01/11] temp: use WIP version of openedx-core --- requirements/edx/base.txt | 2 +- requirements/edx/development.txt | 2 +- requirements/edx/doc.txt | 2 +- requirements/edx/testing.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index 05c9a594b7ec..9040d88e51cb 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -808,7 +808,7 @@ openedx-authz==0.22.0 # via -r requirements/edx/kernel.in openedx-calc==4.0.3 # via -r requirements/edx/kernel.in -openedx-core==0.35.0 +openedx-core @ git+https://github.com/openedx/openedx-core.git@braden/catalog # via # -c requirements/constraints.txt # -r requirements/edx/kernel.in diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt index 8e3f7bb8799b..74eea0bf584d 100644 --- a/requirements/edx/development.txt +++ b/requirements/edx/development.txt @@ -1365,7 +1365,7 @@ openedx-calc==4.0.3 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -openedx-core==0.35.0 +openedx-core @ git+https://github.com/openedx/openedx-core.git@braden/catalog # via # -c requirements/constraints.txt # -r requirements/edx/doc.txt diff --git a/requirements/edx/doc.txt b/requirements/edx/doc.txt index e46f772e584c..8247101cb401 100644 --- a/requirements/edx/doc.txt +++ b/requirements/edx/doc.txt @@ -984,7 +984,7 @@ openedx-authz==0.22.0 # via -r requirements/edx/base.txt openedx-calc==4.0.3 # via -r requirements/edx/base.txt -openedx-core==0.35.0 +openedx-core @ git+https://github.com/openedx/openedx-core.git@braden/catalog # via # -c requirements/constraints.txt # -r requirements/edx/base.txt diff --git a/requirements/edx/testing.txt b/requirements/edx/testing.txt index 6cb004cc1798..6c93732f87c6 100644 --- a/requirements/edx/testing.txt +++ b/requirements/edx/testing.txt @@ -1034,7 +1034,7 @@ openedx-authz==0.22.0 # via -r requirements/edx/base.txt openedx-calc==4.0.3 # via -r requirements/edx/base.txt -openedx-core==0.35.0 +openedx-core @ git+https://github.com/openedx/openedx-core.git@braden/catalog # via # -c requirements/constraints.txt # -r requirements/edx/base.txt From 73f2ad156cc16ff38512844b7c2b00f7c83cb48e Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Wed, 18 Feb 2026 11:41:14 -0800 Subject: [PATCH 02/11] feat: Use openedx_catalog app, backfill it with all known courses --- cms/envs/common.py | 3 + lms/envs/common.py | 3 + .../0030_backfill_new_catalog_courseruns.py | 126 ++++++++++++++++++ 3 files changed, 132 insertions(+) create mode 100644 openedx/core/djangoapps/content/course_overviews/migrations/0030_backfill_new_catalog_courseruns.py diff --git a/cms/envs/common.py b/cms/envs/common.py index 44c4ee4e2f9b..b456579fb622 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -898,6 +898,9 @@ def make_lms_template_path(settings): 'openedx_events', + # Core models to represent courses + "openedx_catalog", + # Core apps that power libraries "openedx_content", *openedx_content_backcompat_apps_to_install(), diff --git a/lms/envs/common.py b/lms/envs/common.py index 917dd025e96f..27e0306711e1 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -2020,6 +2020,9 @@ 'openedx_events', + # Core models to represent courses + "openedx_catalog", + # Core apps that power libraries "openedx_content", *openedx_content_backcompat_apps_to_install(), diff --git a/openedx/core/djangoapps/content/course_overviews/migrations/0030_backfill_new_catalog_courseruns.py b/openedx/core/djangoapps/content/course_overviews/migrations/0030_backfill_new_catalog_courseruns.py new file mode 100644 index 000000000000..1b4cbb436f35 --- /dev/null +++ b/openedx/core/djangoapps/content/course_overviews/migrations/0030_backfill_new_catalog_courseruns.py @@ -0,0 +1,126 @@ +""" +Data migration to populate the new CourseRun and CatalogCourse models. +""" + +# Generated by Django 5.2.11 on 2026-02-13 21:47 +import logging + +from django.conf import settings +from django.db import migrations +from organizations.api import ensure_organization, exceptions as org_exceptions + +log = logging.getLogger(__name__) + + +def backfill_openedx_catalog(apps, schema_editor): + """ + Populate the new CourseRun and CatalogCourse models. + """ + # CourseOverview is a cache model derived from modulestore; modulestore is the source of truth for courses, so we'll + # use it to get the list of "all courses on the system" to populate the new CourseRun and CatalogCourse models. + CourseIndex = apps.get_model("split_modulestore_django", "SplitModulestoreCourseIndex") + CourseOverview = apps.get_model("course_overviews", "CourseOverview") + CatalogCourse = apps.get_model("openedx_catalog", "CatalogCourse") + CourseRun = apps.get_model("openedx_catalog", "CourseRun") + + created_catalog_course_ids: set[int] = set() + all_course_runs = CourseIndex.objects.filter(base_store="mongodb", library_version="").order_by("course_id") + for course_run in all_course_runs: + org_code: str = course_run.course_id.org + course_code: str = course_run.course_id.course + run_code: str = course_run.course_id.run + + # Ensure that the Organization exists. + try: + org_data = ensure_organization(org_code) + except org_exceptions.InvalidOrganizationException as exc: + # Note: IFF the org exists among the modulestore courses but not in the Organizations database table, + # and if auto-create is disabled (it's enabled by default), this will raise InvalidOrganizationException. It + # would be up to the operator to decide how they want to resolve that. + raise ValueError( + f'The organization short code "{org_code}" exists in modulestore ({course_run.course_id}) but ' + "not the Organizations table, and auto-creating organizations is disabled. You can resolve this by " + "creating the Organization manually (e.g. from the Django admin) or turning on auto-creation. " + "You can set active=False to prevent this Organization from being used other than for historical data. " + ) + if org_data["short_name"] != org_code: + # On most installations, the 'short_code' database column is case insensitive (unfortunately) + log.warning( + 'The course with ID "%s" does not match its Organization.short_code "%s"', + course_run.course_id, + org_data["short_name"], + ) + + # Fetch the CourseOverview if it exists + try: + course_overview = CourseOverview.objects.get(id=course_run.course_id) + except CourseOverview.DoesNotExist: + course_overview = None # Course exists in modulestore but details aren't cached into CourseOverview yet + display_name: str = (course_overview.display_name if course_overview else None) or course_code + + # Determine the course language. + language = settings.LANGUAGE_CODE + if course_overview and course_overview.language: + language = course_overview.language.lower() + if len(language) > 2 and language[2] == "_": + language[2] = "-" # Ensure we use hyphens for consistency (`en-us` not `en_us`) + if len(language) > 2 and language[2] not in ("-", "@"): + # This seems like an invalid value; revert to the default: + log.warning( + 'The course with ID "%s" has invalid language "%s" - using default language "%s" instead.', + course_run.course_id, + language, + settings.LANGUAGE_CODE, + ) + language = settings.LANGUAGE_CODE + + # Ensure that the CatalogCourse exists. + cc, created = CatalogCourse.objects.get_or_create( + org_id=org_data["id"], + course_code=course_code, + defaults={ + "display_name": display_name, + "language": language, + }, + ) + if created: + created_catalog_course_ids.add(cc.pk) + elif cc.pk in created_catalog_course_ids: + # This CatalogCourse was previously created during this same migration + # Check if all the runs have the same display_name: + if ( + course_overview + and course_overview.display_name + and course_overview.display_name != cc.display_name + and cc.display_name != course_code + ): + # The runs have different names, so just use the course code as the common catalog course name. + cc.display_name = course_code + cc.save(update_fields=["display_name"]) + + if cc.course_code != course_code: + raise ValueError( + f"The course {course_run.course_id} exists in modulestore with a different capitalization of its " + f'course code compared to other instances of the same run ("{course_code}" vs "{cc.course_code}"). ' + 'This really should not happen. To fix it, delete the inconsistent course runs (!). ' + ) + + # Create the CourseRun + CourseRun.objects.get_or_create( + catalog_course=cc, + run=run_code, + course_id=course_run.course_id, + defaults={"display_name": display_name}, + ) + + +class Migration(migrations.Migration): + dependencies = [ + ("openedx_catalog", "0001_initial"), + ("course_overviews", "0029_alter_historicalcourseoverview_options"), + ("split_modulestore_django", "0003_alter_historicalsplitmodulestorecourseindex_options"), + ] + + operations = [ + migrations.RunPython(backfill_openedx_catalog, reverse_code=migrations.RunPython.noop), + ] From 133d4b080164180cdc9cc4933807aba96c286964 Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Wed, 18 Feb 2026 18:22:07 -0800 Subject: [PATCH 03/11] feat: properly set "created" timestamp on course runs during backfill --- .../0030_backfill_new_catalog_courseruns.py | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/openedx/core/djangoapps/content/course_overviews/migrations/0030_backfill_new_catalog_courseruns.py b/openedx/core/djangoapps/content/course_overviews/migrations/0030_backfill_new_catalog_courseruns.py index 1b4cbb436f35..330f662cdaff 100644 --- a/openedx/core/djangoapps/content/course_overviews/migrations/0030_backfill_new_catalog_courseruns.py +++ b/openedx/core/djangoapps/content/course_overviews/migrations/0030_backfill_new_catalog_courseruns.py @@ -75,7 +75,7 @@ def backfill_openedx_catalog(apps, schema_editor): language = settings.LANGUAGE_CODE # Ensure that the CatalogCourse exists. - cc, created = CatalogCourse.objects.get_or_create( + cc, cc_created = CatalogCourse.objects.get_or_create( org_id=org_data["id"], course_code=course_code, defaults={ @@ -83,7 +83,7 @@ def backfill_openedx_catalog(apps, schema_editor): "language": language, }, ) - if created: + if cc_created: created_catalog_course_ids.add(cc.pk) elif cc.pk in created_catalog_course_ids: # This CatalogCourse was previously created during this same migration @@ -106,13 +106,23 @@ def backfill_openedx_catalog(apps, schema_editor): ) # Create the CourseRun - CourseRun.objects.get_or_create( + new_run, run_created = CourseRun.objects.get_or_create( catalog_course=cc, run=run_code, course_id=course_run.course_id, defaults={"display_name": display_name}, ) + # Correct the "created" timestamp. Since it has auto_now_add=True, we can't set its value except using update() + # The CourseOverview should have the "created" date unless it's missing or the course was created before + # the CourseOverview model existed. In any case, it should be good enough. Otherwise use the default (now). + if course_overview: + if course_overview.created < cc.created and cc.pk in created_catalog_course_ids: + # Use the 'created' date from the oldest course run that we process. + CatalogCourse.objects.filter(pk=cc.pk).update(created=course_overview.created) + if run_created: + CourseRun.objects.filter(pk=new_run.pk).update(created=course_overview.created) + class Migration(migrations.Migration): dependencies = [ From 24c512047bdc1668cddb612e3f22ba26d28c5b6b Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Fri, 20 Feb 2026 16:31:39 -0800 Subject: [PATCH 04/11] docs: correct field name of "short_name" --- .../migrations/0030_backfill_new_catalog_courseruns.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/openedx/core/djangoapps/content/course_overviews/migrations/0030_backfill_new_catalog_courseruns.py b/openedx/core/djangoapps/content/course_overviews/migrations/0030_backfill_new_catalog_courseruns.py index 330f662cdaff..55389158f7f3 100644 --- a/openedx/core/djangoapps/content/course_overviews/migrations/0030_backfill_new_catalog_courseruns.py +++ b/openedx/core/djangoapps/content/course_overviews/migrations/0030_backfill_new_catalog_courseruns.py @@ -42,11 +42,11 @@ def backfill_openedx_catalog(apps, schema_editor): "not the Organizations table, and auto-creating organizations is disabled. You can resolve this by " "creating the Organization manually (e.g. from the Django admin) or turning on auto-creation. " "You can set active=False to prevent this Organization from being used other than for historical data. " - ) + ) from exc if org_data["short_name"] != org_code: - # On most installations, the 'short_code' database column is case insensitive (unfortunately) + # On most installations, the 'short_name' database column is case insensitive (unfortunately) log.warning( - 'The course with ID "%s" does not match its Organization.short_code "%s"', + 'The course with ID "%s" does not match its Organization.short_name "%s"', course_run.course_id, org_data["short_name"], ) From 197017374bac8ba3e79d97659108091ffc2a9c3e Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Sat, 21 Feb 2026 16:10:23 -0800 Subject: [PATCH 05/11] fix: better normalization of language codes --- .../0030_backfill_new_catalog_courseruns.py | 22 +++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/openedx/core/djangoapps/content/course_overviews/migrations/0030_backfill_new_catalog_courseruns.py b/openedx/core/djangoapps/content/course_overviews/migrations/0030_backfill_new_catalog_courseruns.py index 55389158f7f3..63641f3e95d4 100644 --- a/openedx/core/djangoapps/content/course_overviews/migrations/0030_backfill_new_catalog_courseruns.py +++ b/openedx/core/djangoapps/content/course_overviews/migrations/0030_backfill_new_catalog_courseruns.py @@ -11,6 +11,13 @@ log = logging.getLogger(__name__) +# https://github.com/openedx/openedx-platform/issues/38036 +NORMALIZE_LANGUAGE_CODES = { + "zh-hans": "zh-cn", + "zh-hant": "zh-hk", + "ca@valencia": "ca-es-valencia", +} + def backfill_openedx_catalog(apps, schema_editor): """ @@ -59,12 +66,19 @@ def backfill_openedx_catalog(apps, schema_editor): display_name: str = (course_overview.display_name if course_overview else None) or course_code # Determine the course language. + # Note that in Studio, the options for course language generally came from the ALL_LANGUAGES setting, which is + # mostly two-letter language codes with no locale, except it uses "zh_HANS" for Mandarin and "zh_HANT" for + # Cantonese. We normalize those to "zh-cn" and "zh-hk" for consistency with our platform UI languages / + # Transifex, but you can still access the "old" version using the CatalogCourse.language_short + # getter/setter for backwards compatbility. See https://github.com/openedx/openedx-platform/issues/38036 language = settings.LANGUAGE_CODE if course_overview and course_overview.language: language = course_overview.language.lower() - if len(language) > 2 and language[2] == "_": - language[2] = "-" # Ensure we use hyphens for consistency (`en-us` not `en_us`) - if len(language) > 2 and language[2] not in ("-", "@"): + language = language.replace("_", "-") # Ensure we use hyphens for consistency (`en-us` not `en_us`) + # Normalize this language code. The previous/non-normalized code will still be available via the + # "language_short" property for backwards compatibility. + language = NORMALIZE_LANGUAGE_CODES.get(language, language) + if len(language) > 2 and language[2] != "-": # This seems like an invalid value; revert to the default: log.warning( 'The course with ID "%s" has invalid language "%s" - using default language "%s" instead.', @@ -102,7 +116,7 @@ def backfill_openedx_catalog(apps, schema_editor): raise ValueError( f"The course {course_run.course_id} exists in modulestore with a different capitalization of its " f'course code compared to other instances of the same run ("{course_code}" vs "{cc.course_code}"). ' - 'This really should not happen. To fix it, delete the inconsistent course runs (!). ' + "This really should not happen. To fix it, delete the inconsistent course runs (!). " ) # Create the CourseRun From 953637d3d4d197afc79747ab0c495b727c68b48a Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Sat, 21 Feb 2026 16:10:40 -0800 Subject: [PATCH 06/11] feat: keep courses in sync with CourseRun/CatalogCourse --- .../content/course_overviews/signals.py | 47 ++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/openedx/core/djangoapps/content/course_overviews/signals.py b/openedx/core/djangoapps/content/course_overviews/signals.py index 4ae2f4bf0f8a..62eef458cf28 100644 --- a/openedx/core/djangoapps/content/course_overviews/signals.py +++ b/openedx/core/djangoapps/content/course_overviews/signals.py @@ -2,7 +2,6 @@ Signal handler for invalidating cached course overviews """ - import logging from django.db import transaction @@ -10,6 +9,8 @@ from django.dispatch import Signal from django.dispatch.dispatcher import receiver +from openedx_catalog import api as catalog_api +from openedx_catalog.models_api import CourseRun from openedx.core.djangoapps.signals.signals import COURSE_CERT_DATE_CHANGE from xmodule.data import CertificatesDisplayBehaviors from xmodule.modulestore.django import SignalHandler @@ -33,6 +34,8 @@ def _listen_for_course_publish(sender, course_key, **kwargs): # pylint: disable """ Catches the signal that a course has been published in Studio and updates the corresponding CourseOverview cache entry. + + Also sync course data to the openedx_catalog CourseRun model. """ try: previous_course_overview = CourseOverview.objects.get(id=course_key) @@ -41,6 +44,48 @@ def _listen_for_course_publish(sender, course_key, **kwargs): # pylint: disable updated_course_overview = CourseOverview.load_from_module_store(course_key) _check_for_course_changes(previous_course_overview, updated_course_overview) + # Currently, SplitModulestoreCourseIndex is the ultimate source of truth for + # which courses exist. When a course is published, we sync that data to + # CourseOverview, and from CourseOverview to CourseRun. + + # In the future, CourseRun will be the "source of truth" and each CourseRun + # may optionally point to content and get synced to CourseOverview. + + # Ensure a CourseRun exists for this course + try: + course_run = catalog_api.get_course_run(course_key) + except CourseRun.DoesNotExist: + # Presumably this is a newly-created course. Create the CourseRun. + course_run = catalog_api.create_course_run_for_modulestore_course_with( + course_id=course_key, + display_name=updated_course_overview.display_name, + language_short=updated_course_overview.language, + ) + + # Keep the CourseRun up to date as the course is edited: + if updated_course_overview.display_name != course_run.display_name: + catalog_api.sync_course_run_details(course_key, display_name=updated_course_overview.display_name) + + if ( + updated_course_overview.language + and updated_course_overview.language != course_run.catalog_course.language_short + ): + if course_run.catalog_course.runs.count() == 1: + # This is the only run in this CatalogCourse. Update the language of the CatalogCourse + catalog_api.update_catalog_course( + course_run.catalog_course, + language_short=updated_course_overview.language, + ) + else: + LOG.warning( + 'Course run "%s" language "%s" does not match its catalog course language, "%s"', + str(course_key), + updated_course_overview.language, + course_run.catalog_course.language_short, + ) + + # In the future, this will also sync schedule and other metadata to the CourseRun's related models + @receiver(SignalHandler.course_deleted) def _listen_for_course_delete(sender, course_key, **kwargs): # pylint: disable=unused-argument From c569920547e002889b84b8e1a3604302c7e302a0 Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Wed, 25 Feb 2026 09:44:36 -0800 Subject: [PATCH 07/11] test: tests to confirm courses stay in sync with CatalogCourse/CourseRun --- .../content/course_overviews/signals.py | 3 + .../tests/test_sync_with_openedx_catalog.py | 146 ++++++++++++++++++ 2 files changed, 149 insertions(+) create mode 100644 openedx/core/djangoapps/content/course_overviews/tests/test_sync_with_openedx_catalog.py diff --git a/openedx/core/djangoapps/content/course_overviews/signals.py b/openedx/core/djangoapps/content/course_overviews/signals.py index 62eef458cf28..20c75da758cb 100644 --- a/openedx/core/djangoapps/content/course_overviews/signals.py +++ b/openedx/core/djangoapps/content/course_overviews/signals.py @@ -65,6 +65,9 @@ def _listen_for_course_publish(sender, course_key, **kwargs): # pylint: disable # Keep the CourseRun up to date as the course is edited: if updated_course_overview.display_name != course_run.display_name: catalog_api.sync_course_run_details(course_key, display_name=updated_course_overview.display_name) + # If this course is the only run in the CatalogCourse, should we update the display_name of + # the CatalogCourse to match the run's new name? Currently the only way to edit the name of + # a CatalogCourse is via the Django admin. But it's also not used anywhere yet. if ( updated_course_overview.language diff --git a/openedx/core/djangoapps/content/course_overviews/tests/test_sync_with_openedx_catalog.py b/openedx/core/djangoapps/content/course_overviews/tests/test_sync_with_openedx_catalog.py new file mode 100644 index 000000000000..5187e5bd1e3d --- /dev/null +++ b/openedx/core/djangoapps/content/course_overviews/tests/test_sync_with_openedx_catalog.py @@ -0,0 +1,146 @@ +""" +Test that changes to courses get synced into the new openedx_catalog models. +""" + +from openedx_catalog import api as catalog_api + +from cms.djangoapps.contentstore.views.course import rerun_course +from xmodule.modulestore import ModuleStoreEnum +from xmodule.modulestore.tests.django_utils import ( + TEST_DATA_ONLY_SPLIT_MODULESTORE_DRAFT_PREFERRED, + ModuleStoreTestCase, + ImmediateOnCommitMixin, +) +from xmodule.modulestore.tests.factories import CourseFactory + + +class CourseOverviewSyncTestCase(ImmediateOnCommitMixin, ModuleStoreTestCase): + """ + Test that changes to courses get synced into the new openedx_catalog models. + """ + + MODULESTORE = TEST_DATA_ONLY_SPLIT_MODULESTORE_DRAFT_PREFERRED + ENABLED_SIGNALS = ["course_published"] + + def test_courserun_creation(self) -> None: + """ + Tests that when a course is created, the `CourseRun` record gets created. + + (Also the corresponding `CatalogCourse`.) + """ + course = CourseFactory.create(display_name="Intro to Testing", emit_signals=True) + course_id = course.location.context_key + + run = catalog_api.get_course_run(course_id) + assert run.display_name == "Intro to Testing" + assert run.course_id == course_id + assert run.catalog_course.course_code == course_id.course + assert run.catalog_course.org_code == course_id.org + + def test_courserun_sync(self) -> None: + """ + Tests that when a course is updated, the catalog records get updated. + + Because the "language" of a course cannot be set in Studio before you + create the course, when a Catalog Course has only a single run, we need + to keep the language of the catalog course in sync with any changes to + the language field of the course run. (Because authors necessarily + create a new course with the default language then edit it to have the + correct language that they actually intended to use for that [catalog] + course.) This is in contrast with display_name, which can actually be + set before creating a course. + """ + # Create a course + course = CourseFactory.create(display_name="Intro to Testing", emit_signals=True) + course_id = course.location.context_key + run = catalog_api.get_course_run(course_id) + assert run.display_name == "Intro to Testing" + assert run.catalog_course.language_short == "en" + + # Update the course's display_name and language: + course.language = "es" + course.display_name = "Introducción a las pruebas" + self.store.update_item(course, ModuleStoreEnum.UserID.test) + + # Check if the catalog data is updated: + run.refresh_from_db() + assert run.display_name == "Introducción a las pruebas" + assert run.catalog_course.language_short == "es" + # Note: for now we don't update the display_name of the catalog course after it has been created. + # We _could_ decide to sync the name from run -> catalog course if there is only one run. + assert run.catalog_course.display_name == "Intro to Testing" + + def test_courserun_sync(self) -> None: + """ + Tests that when a course is updated, the catalog records get updated. + + Because the "language" of a course cannot be set in Studio before you + create the course, when a Catalog Course has only a single run, we need + to keep the language of the catalog course in sync with any changes to + the language field of the course run. (Because authors necessarily + create a new course with the default language then edit it to have the + correct language that they actually intended to use for that [catalog] + course.) This is in contrast with display_name, which can actually be + set before creating a course. + """ + # Create a course + course = CourseFactory.create(display_name="Intro to Testing", emit_signals=True) + course_id = course.location.context_key + run = catalog_api.get_course_run(course_id) + assert run.display_name == "Intro to Testing" + assert run.catalog_course.language_short == "en" + + # Update the course's display_name and language: + course.language = "es" + course.display_name = "Introducción a las pruebas" + self.store.update_item(course, ModuleStoreEnum.UserID.test) + + # Check if the catalog data is updated: + run.refresh_from_db() + assert run.display_name == "Introducción a las pruebas" + assert run.catalog_course.language_short == "es" + # Note: for now we don't update the display_name of the catalog course after it has been created. + # We _could_ decide to sync the name from run -> catalog course if there is only one run. + assert run.catalog_course.display_name == "Intro to Testing" + + def test_courserun_of_many_sync(self) -> None: + """ + Tests that when a course is updated, the catalog records get updated, + but if there are several runs of the same course, the changes don't + propagate to the `CatalogCourse` and only affect the `CourseRun. + """ + # Create a course + course = CourseFactory.create(display_name="Intro to Testing", emit_signals=True) + course_id = course.location.context_key + run = catalog_api.get_course_run(course_id) + assert run.display_name == "Intro to Testing" + assert run.catalog_course.language_short == "en" + + # re-run the course: + new_run_course_id = rerun_course( + self.user, + source_course_key=course_id, + org=course_id.org, + number=course_id.course, + run="newRUN", + fields={"display_name": "Intro to Testing TEMPORARY NAME"}, + background=False, + ) + + # Update the re-run's display_name and language: + new_course = self.store.get_course(new_run_course_id) + new_course.language = "es" + new_course.display_name = "Introducción a las pruebas" + self.store.update_item(new_course, self.user.id) + + # Check if the catalog data is updated correctly. + # The original CourseRun object should be unchanged: + run.refresh_from_db() + assert run.display_name == "Intro to Testing" + assert run.catalog_course.language_short == "en" + # The new CourseRun object should be created: + new_run = catalog_api.get_course_run(new_run_course_id) + assert new_run.display_name == "Introducción a las pruebas" + # Changing the language of the second run doesn't affect the lanugage of the overall catalog course (since the + # first run is still in English) + assert new_run.catalog_course.language_short == "en" From 84e3acfe4ab9d1f7563996101a4a583f32a43a13 Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Wed, 25 Feb 2026 17:59:55 -0800 Subject: [PATCH 08/11] feat: delete CourseRun/CatalogCourse when deleting a course --- .../content/course_overviews/signals.py | 10 +++++ .../tests/test_sync_with_openedx_catalog.py | 44 ++++++++++++++++++- 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/openedx/core/djangoapps/content/course_overviews/signals.py b/openedx/core/djangoapps/content/course_overviews/signals.py index 20c75da758cb..2314056db5ca 100644 --- a/openedx/core/djangoapps/content/course_overviews/signals.py +++ b/openedx/core/djangoapps/content/course_overviews/signals.py @@ -104,6 +104,16 @@ def _listen_for_course_delete(sender, course_key, **kwargs): # pylint: disable= sender=None, courserun_key=courserun_key, ) + # Delete the openedx_catalog CourseRun to keep it in sync: + try: + course_run_obj = catalog_api.get_course_run(course_key) + except CourseRun.DoesNotExist: + pass + else: + catalog_course = course_run_obj.catalog_course + catalog_api.delete_course_run(course_key) + if catalog_course.runs.count() == 0: + catalog_api.delete_catalog_course(catalog_course) @receiver(post_save, sender=CourseOverview) diff --git a/openedx/core/djangoapps/content/course_overviews/tests/test_sync_with_openedx_catalog.py b/openedx/core/djangoapps/content/course_overviews/tests/test_sync_with_openedx_catalog.py index 5187e5bd1e3d..ba3c1853a930 100644 --- a/openedx/core/djangoapps/content/course_overviews/tests/test_sync_with_openedx_catalog.py +++ b/openedx/core/djangoapps/content/course_overviews/tests/test_sync_with_openedx_catalog.py @@ -2,7 +2,10 @@ Test that changes to courses get synced into the new openedx_catalog models. """ +import pytest + from openedx_catalog import api as catalog_api +from openedx_catalog.models_api import CatalogCourse, CourseRun from cms.djangoapps.contentstore.views.course import rerun_course from xmodule.modulestore import ModuleStoreEnum @@ -20,7 +23,7 @@ class CourseOverviewSyncTestCase(ImmediateOnCommitMixin, ModuleStoreTestCase): """ MODULESTORE = TEST_DATA_ONLY_SPLIT_MODULESTORE_DRAFT_PREFERRED - ENABLED_SIGNALS = ["course_published"] + ENABLED_SIGNALS = ["course_deleted", "course_published"] def test_courserun_creation(self) -> None: """ @@ -144,3 +147,42 @@ def test_courserun_of_many_sync(self) -> None: # Changing the language of the second run doesn't affect the lanugage of the overall catalog course (since the # first run is still in English) assert new_run.catalog_course.language_short == "en" + + def test_courserun_deletion(self) -> None: + """ + Tests that when a course run is deleted, the corresponding CourseRun is + deleted, and when it's the last run, the CatalogCourse is deleted too. + """ + # Create a course with two runs: + course = CourseFactory.create(display_name="Intro to Testing", emit_signals=True) + course_id1 = course.location.context_key + run1 = catalog_api.get_course_run(course_id1) + # re-run the course: + course_id2 = rerun_course( + self.user, + source_course_key=course_id1, + org=course_id1.org, + number=course_id1.course, + run="run2", + fields={"display_name": "ItT run2"}, + background=False, + ) + run2 = catalog_api.get_course_run(course_id2) + catalog_course = run1.catalog_course + assert catalog_course == run2.catalog_course # Same for run1 and run2 + + self.store.delete_course(course_id1, ModuleStoreEnum.UserID.test) + with pytest.raises(CourseRun.DoesNotExist): + run1.refresh_from_db() + + # run2 should still exist: + run2.refresh_from_db() + assert run2.catalog_course.display_name == "Intro to Testing" # The catalog course still exists and works + + # delete run 2: + self.store.delete_course(course_id2, ModuleStoreEnum.UserID.test) + with pytest.raises(CourseRun.DoesNotExist): + run2.refresh_from_db() + # With no runs left, the CatalogCourse also gets auto-deleted: + with pytest.raises(CatalogCourse.DoesNotExist): + catalog_course.refresh_from_db() From 2902fe8bc64374529d9de07cbcc08154bcdb0d96 Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Wed, 25 Feb 2026 18:03:32 -0800 Subject: [PATCH 09/11] test: update import linter --- setup.cfg | 2 ++ 1 file changed, 2 insertions(+) diff --git a/setup.cfg b/setup.cfg index 28ea010b5e11..efce53ea5e05 100644 --- a/setup.cfg +++ b/setup.cfg @@ -185,10 +185,12 @@ isolated_apps = openedx.core.djangoapps.olx_rest_api openedx.core.djangoapps.xblock openedx.core.lib.xblock_serializer + openedx_catalog allowed_modules = # Only imports from api.py and data.py are allowed elsewhere in the code # See https://open-edx-proposals.readthedocs.io/en/latest/best-practices/oep-0049-django-app-patterns.html#api-py api + models_api data tests From be726d383fd0b9926d8c600e2eeb65827349a691 Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Thu, 26 Feb 2026 14:19:36 -0800 Subject: [PATCH 10/11] test: fix issue with the tests running on CI --- .../tests/test_sync_with_openedx_catalog.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/openedx/core/djangoapps/content/course_overviews/tests/test_sync_with_openedx_catalog.py b/openedx/core/djangoapps/content/course_overviews/tests/test_sync_with_openedx_catalog.py index ba3c1853a930..79f77a9da314 100644 --- a/openedx/core/djangoapps/content/course_overviews/tests/test_sync_with_openedx_catalog.py +++ b/openedx/core/djangoapps/content/course_overviews/tests/test_sync_with_openedx_catalog.py @@ -7,7 +7,7 @@ from openedx_catalog import api as catalog_api from openedx_catalog.models_api import CatalogCourse, CourseRun -from cms.djangoapps.contentstore.views.course import rerun_course +from openedx.core.djangolib.testing.utils import skip_unless_cms from xmodule.modulestore import ModuleStoreEnum from xmodule.modulestore.tests.django_utils import ( TEST_DATA_ONLY_SPLIT_MODULESTORE_DRAFT_PREFERRED, @@ -17,6 +17,7 @@ from xmodule.modulestore.tests.factories import CourseFactory +@skip_unless_cms class CourseOverviewSyncTestCase(ImmediateOnCommitMixin, ModuleStoreTestCase): """ Test that changes to courses get synced into the new openedx_catalog models. @@ -112,6 +113,8 @@ def test_courserun_of_many_sync(self) -> None: but if there are several runs of the same course, the changes don't propagate to the `CatalogCourse` and only affect the `CourseRun. """ + # This import causes problems at top level when tests run on the LMS shard + from cms.djangoapps.contentstore.views.course import rerun_course # Create a course course = CourseFactory.create(display_name="Intro to Testing", emit_signals=True) course_id = course.location.context_key @@ -153,6 +156,8 @@ def test_courserun_deletion(self) -> None: Tests that when a course run is deleted, the corresponding CourseRun is deleted, and when it's the last run, the CatalogCourse is deleted too. """ + # This import causes problems at top level when tests run on the LMS shard + from cms.djangoapps.contentstore.views.course import rerun_course # Create a course with two runs: course = CourseFactory.create(display_name="Intro to Testing", emit_signals=True) course_id1 = course.location.context_key From a54bbaae267ce870b507935a498ad4967bd06966 Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Thu, 26 Feb 2026 17:04:43 -0800 Subject: [PATCH 11/11] test: remove duplicate test --- .../tests/test_sync_with_openedx_catalog.py | 33 ------------------- 1 file changed, 33 deletions(-) diff --git a/openedx/core/djangoapps/content/course_overviews/tests/test_sync_with_openedx_catalog.py b/openedx/core/djangoapps/content/course_overviews/tests/test_sync_with_openedx_catalog.py index 79f77a9da314..7fbc16cfe322 100644 --- a/openedx/core/djangoapps/content/course_overviews/tests/test_sync_with_openedx_catalog.py +++ b/openedx/core/djangoapps/content/course_overviews/tests/test_sync_with_openedx_catalog.py @@ -74,39 +74,6 @@ def test_courserun_sync(self) -> None: # We _could_ decide to sync the name from run -> catalog course if there is only one run. assert run.catalog_course.display_name == "Intro to Testing" - def test_courserun_sync(self) -> None: - """ - Tests that when a course is updated, the catalog records get updated. - - Because the "language" of a course cannot be set in Studio before you - create the course, when a Catalog Course has only a single run, we need - to keep the language of the catalog course in sync with any changes to - the language field of the course run. (Because authors necessarily - create a new course with the default language then edit it to have the - correct language that they actually intended to use for that [catalog] - course.) This is in contrast with display_name, which can actually be - set before creating a course. - """ - # Create a course - course = CourseFactory.create(display_name="Intro to Testing", emit_signals=True) - course_id = course.location.context_key - run = catalog_api.get_course_run(course_id) - assert run.display_name == "Intro to Testing" - assert run.catalog_course.language_short == "en" - - # Update the course's display_name and language: - course.language = "es" - course.display_name = "Introducción a las pruebas" - self.store.update_item(course, ModuleStoreEnum.UserID.test) - - # Check if the catalog data is updated: - run.refresh_from_db() - assert run.display_name == "Introducción a las pruebas" - assert run.catalog_course.language_short == "es" - # Note: for now we don't update the display_name of the catalog course after it has been created. - # We _could_ decide to sync the name from run -> catalog course if there is only one run. - assert run.catalog_course.display_name == "Intro to Testing" - def test_courserun_of_many_sync(self) -> None: """ Tests that when a course is updated, the catalog records get updated,