|
| 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) |
0 commit comments