Skip to content

Commit 73f2ad1

Browse files
feat: Use openedx_catalog app, backfill it with all known courses
1 parent 97e1631 commit 73f2ad1

3 files changed

Lines changed: 132 additions & 0 deletions

File tree

cms/envs/common.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -898,6 +898,9 @@ def make_lms_template_path(settings):
898898

899899
'openedx_events',
900900

901+
# Core models to represent courses
902+
"openedx_catalog",
903+
901904
# Core apps that power libraries
902905
"openedx_content",
903906
*openedx_content_backcompat_apps_to_install(),

lms/envs/common.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2020,6 +2020,9 @@
20202020

20212021
'openedx_events',
20222022

2023+
# Core models to represent courses
2024+
"openedx_catalog",
2025+
20232026
# Core apps that power libraries
20242027
"openedx_content",
20252028
*openedx_content_backcompat_apps_to_install(),
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
"""
2+
Data migration to populate the new CourseRun and CatalogCourse models.
3+
"""
4+
5+
# Generated by Django 5.2.11 on 2026-02-13 21:47
6+
import logging
7+
8+
from django.conf import settings
9+
from django.db import migrations
10+
from organizations.api import ensure_organization, exceptions as org_exceptions
11+
12+
log = logging.getLogger(__name__)
13+
14+
15+
def backfill_openedx_catalog(apps, schema_editor):
16+
"""
17+
Populate the new CourseRun and CatalogCourse models.
18+
"""
19+
# CourseOverview is a cache model derived from modulestore; modulestore is the source of truth for courses, so we'll
20+
# use it to get the list of "all courses on the system" to populate the new CourseRun and CatalogCourse models.
21+
CourseIndex = apps.get_model("split_modulestore_django", "SplitModulestoreCourseIndex")
22+
CourseOverview = apps.get_model("course_overviews", "CourseOverview")
23+
CatalogCourse = apps.get_model("openedx_catalog", "CatalogCourse")
24+
CourseRun = apps.get_model("openedx_catalog", "CourseRun")
25+
26+
created_catalog_course_ids: set[int] = set()
27+
all_course_runs = CourseIndex.objects.filter(base_store="mongodb", library_version="").order_by("course_id")
28+
for course_run in all_course_runs:
29+
org_code: str = course_run.course_id.org
30+
course_code: str = course_run.course_id.course
31+
run_code: str = course_run.course_id.run
32+
33+
# Ensure that the Organization exists.
34+
try:
35+
org_data = ensure_organization(org_code)
36+
except org_exceptions.InvalidOrganizationException as exc:
37+
# Note: IFF the org exists among the modulestore courses but not in the Organizations database table,
38+
# and if auto-create is disabled (it's enabled by default), this will raise InvalidOrganizationException. It
39+
# would be up to the operator to decide how they want to resolve that.
40+
raise ValueError(
41+
f'The organization short code "{org_code}" exists in modulestore ({course_run.course_id}) but '
42+
"not the Organizations table, and auto-creating organizations is disabled. You can resolve this by "
43+
"creating the Organization manually (e.g. from the Django admin) or turning on auto-creation. "
44+
"You can set active=False to prevent this Organization from being used other than for historical data. "
45+
)
46+
if org_data["short_name"] != org_code:
47+
# On most installations, the 'short_code' database column is case insensitive (unfortunately)
48+
log.warning(
49+
'The course with ID "%s" does not match its Organization.short_code "%s"',
50+
course_run.course_id,
51+
org_data["short_name"],
52+
)
53+
54+
# Fetch the CourseOverview if it exists
55+
try:
56+
course_overview = CourseOverview.objects.get(id=course_run.course_id)
57+
except CourseOverview.DoesNotExist:
58+
course_overview = None # Course exists in modulestore but details aren't cached into CourseOverview yet
59+
display_name: str = (course_overview.display_name if course_overview else None) or course_code
60+
61+
# Determine the course language.
62+
language = settings.LANGUAGE_CODE
63+
if course_overview and course_overview.language:
64+
language = course_overview.language.lower()
65+
if len(language) > 2 and language[2] == "_":
66+
language[2] = "-" # Ensure we use hyphens for consistency (`en-us` not `en_us`)
67+
if len(language) > 2 and language[2] not in ("-", "@"):
68+
# This seems like an invalid value; revert to the default:
69+
log.warning(
70+
'The course with ID "%s" has invalid language "%s" - using default language "%s" instead.',
71+
course_run.course_id,
72+
language,
73+
settings.LANGUAGE_CODE,
74+
)
75+
language = settings.LANGUAGE_CODE
76+
77+
# Ensure that the CatalogCourse exists.
78+
cc, created = CatalogCourse.objects.get_or_create(
79+
org_id=org_data["id"],
80+
course_code=course_code,
81+
defaults={
82+
"display_name": display_name,
83+
"language": language,
84+
},
85+
)
86+
if created:
87+
created_catalog_course_ids.add(cc.pk)
88+
elif cc.pk in created_catalog_course_ids:
89+
# This CatalogCourse was previously created during this same migration
90+
# Check if all the runs have the same display_name:
91+
if (
92+
course_overview
93+
and course_overview.display_name
94+
and course_overview.display_name != cc.display_name
95+
and cc.display_name != course_code
96+
):
97+
# The runs have different names, so just use the course code as the common catalog course name.
98+
cc.display_name = course_code
99+
cc.save(update_fields=["display_name"])
100+
101+
if cc.course_code != course_code:
102+
raise ValueError(
103+
f"The course {course_run.course_id} exists in modulestore with a different capitalization of its "
104+
f'course code compared to other instances of the same run ("{course_code}" vs "{cc.course_code}"). '
105+
'This really should not happen. To fix it, delete the inconsistent course runs (!). '
106+
)
107+
108+
# Create the CourseRun
109+
CourseRun.objects.get_or_create(
110+
catalog_course=cc,
111+
run=run_code,
112+
course_id=course_run.course_id,
113+
defaults={"display_name": display_name},
114+
)
115+
116+
117+
class Migration(migrations.Migration):
118+
dependencies = [
119+
("openedx_catalog", "0001_initial"),
120+
("course_overviews", "0029_alter_historicalcourseoverview_options"),
121+
("split_modulestore_django", "0003_alter_historicalsplitmodulestorecourseindex_options"),
122+
]
123+
124+
operations = [
125+
migrations.RunPython(backfill_openedx_catalog, reverse_code=migrations.RunPython.noop),
126+
]

0 commit comments

Comments
 (0)