Skip to content

Commit 1c5643a

Browse files
feat: add an initial API for catalog courses / course runs
1 parent 58bd13c commit 1c5643a

File tree

3 files changed

+216
-0
lines changed

3 files changed

+216
-0
lines changed

src/openedx_catalog/api.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
"""
2+
Open edX Core Catalog API
3+
4+
Note: this is currently a very minimal API. At this point, the openedx_catalog
5+
app mainly exists to provide core models that represent "catalog courses" and
6+
"course runs" for use by foreign keys across the system.
7+
8+
If a course "exists" in the system, you can trust that it will exist as a
9+
CatalogCourse and CourseRun row in this openedx_catalog app, and use those as
10+
needed when creating foreign keys in various apps. This should be much more
11+
efficient than storing the full course ID as a string or creating a foreign key
12+
to the (large) CourseOverview table.
13+
14+
Note that the opposite does not hold. Admins can now create CourseRuns and/or
15+
CatalogCourses that don't yet have any content attached. So you may find entries
16+
in this openedx_catalog app that don't correspond to courses in modulestore.
17+
18+
In addition, we currently do not account for which courses should be visible to
19+
which users. So this API does not yet provide any "list courses" methods. In the
20+
future, the catalog API will be extended to implement course listing along with
21+
pluggable logic for managing multiple catalogs of courses that can account for
22+
instance-specific logic (e.g. enterprise, subscriptions, white labelling) when
23+
determining which courses are visible to which users.
24+
"""
25+
26+
# Import only the public API methods denoted with __all__
27+
# pylint: disable=wildcard-import
28+
from .api_impl import *
29+
30+
# You'll also want the models from .models_api

src/openedx_catalog/api_impl.py

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
"""
2+
Implementation of the `openedx_catalog` API.
3+
"""
4+
5+
import logging
6+
7+
from django.conf import settings
8+
from opaque_keys.edx.keys import CourseKey
9+
from organizations.api import ensure_organization
10+
from organizations.api import exceptions as org_exceptions
11+
12+
from .models import CatalogCourse, CourseRun
13+
14+
log = logging.getLogger(__name__)
15+
16+
# These are the public API methods that anyone can use
17+
__all__ = [
18+
"get_catalog_course",
19+
"get_course_run",
20+
"sync_course_run_details",
21+
"create_course_run_for_modulestore_course_with",
22+
"update_catalog_course",
23+
]
24+
25+
26+
def get_catalog_course(org_code: str, course_code: str) -> CatalogCourse:
27+
"""
28+
Get a catalog course (set of runs).
29+
30+
Does not check permissions nor load related models.
31+
32+
The CatalogCourse may not have any runs associated with it.
33+
"""
34+
return CatalogCourse.objects.get(org__short_name=org_code, course_code=course_code)
35+
36+
37+
def get_course_run(course_id: CourseKey) -> CourseRun:
38+
"""
39+
Get a single course run. Does not check permissions nor load related models.
40+
41+
The CourseRun may or may not have content associated with it.
42+
"""
43+
return CourseRun.objects.get(course_id__exact=course_id)
44+
45+
46+
def sync_course_run_details(
47+
course_id: CourseKey,
48+
*,
49+
display_name: str | None, # Specify a string to change the display name.
50+
) -> None:
51+
"""
52+
Update a `CourseRun` with details from a more authoritative model (e.g.
53+
`CourseOverview`). Currently the only field that can be updated is
54+
`display_name`.
55+
56+
The name of this function reflects the fact that the `CourseRun` model is
57+
not currently a source of truth. So it's not a "rename the course" API, but
58+
rather a "some other part of the system already renamed the course" API,
59+
during a transition period until `CourseRun` is the main source of truth.
60+
61+
Once `CourseRun` is the main source of truth, this will be replaced with a
62+
`update_course_run` API that will become the main way to rename a course.
63+
64+
⚠️ Does not check permissions.
65+
"""
66+
run = CourseRun.objects.get(course_id=course_id)
67+
if display_name:
68+
run.display_name = display_name
69+
run.save(update_fields=["display_name"])
70+
71+
72+
def create_course_run_for_modulestore_course_with(
73+
course_id: CourseKey,
74+
*,
75+
display_name: str,
76+
# The short language code (one of settings.ALL_LANGUAGES), e.g. "en", "es", "zh_HANS"
77+
language_short: str | None = None,
78+
) -> CourseRun:
79+
"""
80+
Create a `CourseRun` (and, if necessary, its corresponding `CatalogCourse`).
81+
This API is meant to be used for data synchonrization purposes (keeping the
82+
new catalog models in sync with modulestore), and is not a generic "create a
83+
course run" API.
84+
85+
If the `CourseRun` already exists, this will log a warning.
86+
87+
The `created` timestamp of the `CourseRun` will be set to now, so this is
88+
not meant for historical data (use a data migration).
89+
90+
⚠️ Does not check permissions.
91+
"""
92+
# Note: this code shares a lot with the code in
93+
# openedx-platform/openedx/core/djangoapps/content/course_overviews/migrations/0030_backfill_...
94+
# but migrations should generally represent a point-in-time transformation, not call an API method that may continue
95+
# to be developed. So even though it's not DRY, the code is repeated here.
96+
97+
org_code = course_id.org
98+
course_code = course_id.course
99+
try:
100+
cc = CatalogCourse.objects.get(org__short_name=org_code, course_code=course_code)
101+
except CatalogCourse.DoesNotExist:
102+
cc = None
103+
104+
if not cc:
105+
# Create the catalog course.
106+
107+
# First, ensure that the Organization exists.
108+
try:
109+
org_data = ensure_organization(org_code)
110+
except org_exceptions.InvalidOrganizationException as exc:
111+
# Note: IFF the org exists among the modulestore courses but not in the Organizations database table,
112+
# and if auto-create is disabled (it's enabled by default), this will raise InvalidOrganizationException. It
113+
# would be up to the operator to decide how they want to resolve that.
114+
raise ValueError(
115+
f'The organization short code "{org_code}" exists in modulestore ({str(course_id)}) but '
116+
"not the Organizations table, and auto-creating organizations is disabled. You can resolve this by "
117+
"creating the Organization manually (e.g. from the Django admin) or turning on auto-creation. "
118+
"You can set active=False to prevent this Organization from being used other than for historical data. "
119+
) from exc
120+
if org_data["short_name"] != org_code:
121+
# On most installations, the 'short_name' database column is case insensitive (unfortunately)
122+
log.warning(
123+
'The course with ID "%s" does not match its Organization.short_name "%s"',
124+
str(course_id),
125+
org_data["short_name"],
126+
)
127+
128+
# Actually create the CatalogCourse. We use get_or_create just to be extra robust against race conditions, since
129+
# we don't care if another worker/thread/etc has beaten us to creating this.
130+
cc, _cc_created = CatalogCourse.objects.get_or_create(
131+
org_id=org_data["id"],
132+
course_code=course_code,
133+
defaults={
134+
"display_name": display_name,
135+
"language_short": language_short,
136+
},
137+
)
138+
139+
new_run, created = CourseRun.objects.get_or_create(
140+
catalog_course=cc,
141+
run=course_id.run,
142+
course_id=course_id,
143+
defaults={"display_name": display_name},
144+
)
145+
146+
if not created:
147+
log.warning('Expected to create CourseRun for "%s" but it already existed.', str(course_id))
148+
149+
return new_run
150+
151+
152+
def update_catalog_course(
153+
catalog_course: CatalogCourse | int,
154+
*,
155+
display_name: str | None = None, # Specify a string to change the display name.
156+
# The short language code (one of settings.ALL_LANGUAGES), e.g. "en", "es", "zh_HANS"
157+
language_short: str | None = None,
158+
) -> None:
159+
"""
160+
Update a `CatalogCourse`.
161+
162+
⚠️ Does not check permissions.
163+
"""
164+
if isinstance(catalog_course, CatalogCourse):
165+
cc = catalog_course
166+
else:
167+
cc = CatalogCourse.objects.get(pk=catalog_course)
168+
169+
update_fields = []
170+
if display_name:
171+
cc.display_name = display_name
172+
update_fields.append("display_name")
173+
if language_short:
174+
cc.language_short = language_short
175+
update_fields.append("language")
176+
if update_fields:
177+
cc.save(update_fields=update_fields)

src/openedx_catalog/models_api.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
"""
2+
Core models available for use in other apps. These are mostly meant to be used
3+
as foreign key targets. Each model should be considered read-only and only
4+
mutated using API methods available in `openedx_catalog.api`.
5+
6+
See the `openedx_catalog.api` docstring for much more details.
7+
"""
8+
9+
from .models import CatalogCourse, CourseRun

0 commit comments

Comments
 (0)