Skip to content

Commit b83e3f7

Browse files
feat: API for deleting a course run and/or catalog course
1 parent d804a4e commit b83e3f7

File tree

2 files changed

+65
-0
lines changed

2 files changed

+65
-0
lines changed

src/openedx_catalog/api_impl.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,11 @@
1717
__all__ = [
1818
"get_catalog_course",
1919
"update_catalog_course",
20+
"delete_catalog_course",
2021
"get_course_run",
2122
"sync_course_run_details",
2223
"create_course_run_for_modulestore_course_with",
24+
"delete_course_run",
2325
]
2426

2527

@@ -84,6 +86,20 @@ def update_catalog_course(
8486
cc.save(update_fields=update_fields)
8587

8688

89+
def delete_catalog_course(catalog_course: CatalogCourse | int) -> None:
90+
"""
91+
Delete a `CatalogCourse`. This will fail with a `ProtectedError` if any runs exist.
92+
93+
⚠️ Does not check permissions.
94+
⚠️ Does not emit any course lifecycle events.
95+
"""
96+
if isinstance(catalog_course, CatalogCourse):
97+
cc = catalog_course
98+
else:
99+
cc = CatalogCourse.objects.get(pk=catalog_course)
100+
cc.delete()
101+
102+
87103
def get_course_run(course_id: CourseKey) -> CourseRun:
88104
"""
89105
Get a single course run.
@@ -117,6 +133,7 @@ def sync_course_run_details(
117133
`update_course_run` API that will become the main way to rename a course.
118134
119135
⚠️ Does not check permissions.
136+
⚠️ Does not emit any course lifecycle events.
120137
"""
121138
run = CourseRun.objects.get(course_id=course_id)
122139
if display_name:
@@ -143,6 +160,7 @@ def create_course_run_for_modulestore_course_with(
143160
not meant for historical data (use a data migration).
144161
145162
⚠️ Does not check permissions.
163+
⚠️ Does not emit any course lifecycle events.
146164
"""
147165
# Note: this code shares a lot with the code in
148166
# openedx-platform/openedx/core/djangoapps/content/course_overviews/migrations/0030_backfill_...
@@ -202,3 +220,16 @@ def create_course_run_for_modulestore_course_with(
202220
log.warning('Expected to create CourseRun for "%s" but it already existed.', str(course_id))
203221

204222
return new_run
223+
224+
225+
def delete_course_run(course_id: CourseKey) -> None:
226+
"""
227+
Delete a `CourseRun`.
228+
229+
This may fail with a `ProtectedError` or other `IntegrityError` subclass if
230+
there are still active references to the course run.
231+
232+
⚠️ Does not check permissions.
233+
⚠️ Does not emit any course lifecycle events.
234+
"""
235+
CourseRun.objects.get(course_id=course_id).delete()

tests/openedx_catalog/test_api.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
import pytest
99
from django.db import connection
10+
from django.db.models import ProtectedError
1011
from django.test import override_settings
1112
from freezegun import freeze_time
1213
from opaque_keys.edx.keys import CourseKey
@@ -141,6 +142,22 @@ def test_update_ignore_other_fields(python100: CatalogCourse) -> None:
141142
assert python100.display_name == "New Name"
142143

143144

145+
# delete_catalog_course
146+
147+
148+
def test_delete_catalog_course(python100: CatalogCourse, python100_summer26: CourseRun) -> None:
149+
"""Test that we can delete a CatalogCourse but only if no runs exist"""
150+
# At first, deletion will fail because of the Summer2026 run:
151+
with pytest.raises(ProtectedError):
152+
api.delete_catalog_course(python100)
153+
python100.refresh_from_db() # Make sure it's not deleted.
154+
# Now delete the run, unblocking deletion of the catalog course:
155+
python100_summer26.delete() # FIXME: use an API method for this.
156+
api.delete_catalog_course(python100)
157+
with pytest.raises(CatalogCourse.DoesNotExist):
158+
python100.refresh_from_db() # Make sure it's gone
159+
160+
144161
# get_course_run
145162

146163

@@ -340,3 +357,20 @@ def test_create_course_run_for_modulestore_course_run_that_exists(caplog: pytest
340357
assert new_run.display_name == "Original Name"
341358
assert new_run.catalog_course.display_name == "Original Name"
342359
assert new_run.run == run_code
360+
361+
362+
# delete_course_run
363+
364+
365+
def test_delete_course_run(
366+
python100: CatalogCourse,
367+
python100_summer26: CourseRun,
368+
python100_winter26: CourseRun,
369+
) -> None:
370+
"""Test that we can delete a CourseRun, passing in the object"""
371+
api.delete_course_run(python100_summer26.course_id)
372+
with pytest.raises(CourseRun.DoesNotExist):
373+
python100_summer26.refresh_from_db() # Make sure it's gone
374+
# The catalog course and other run is unaffected:
375+
python100.refresh_from_db()
376+
python100_winter26.refresh_from_db()

0 commit comments

Comments
 (0)