Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions RELEASE.rst
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
Release Notes
=============

Version 0.131.4
---------------

- Add functionality to verify course modes exist when cloning a course run (#2993)
- fix(deps): update dependency @sentry/browser to v10 (#2656)

Version 0.131.3 (Released October 15, 2025)
---------------

Expand Down
64 changes: 58 additions & 6 deletions courses/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,11 @@
)
from ecommerce.models import OrderStatus
from openedx.api import (
create_edx_course_mode,
enroll_in_edx_course_runs,
get_edx_api_course_list_client,
get_edx_api_course_mode_client,
get_edx_course_modes,
get_edx_grades_with_users,
unenroll_edx_course_run,
)
Expand Down Expand Up @@ -619,15 +621,65 @@ def sync_course_runs(runs):
return success_count, failure_count


def sync_course_mode(runs: list[CourseRun]) -> list[str]:
def check_course_modes(run: CourseRun) -> tuple[bool, bool]:
"""
Updates course run upgrade expiration dates from Open edX
Check that the course has the course modes we expect.

We expect an `audit` and a `verified` mode in our course runs. If these don't
exist for the given course, this will create them.

Args:
runs ([CourseRun]): list of CourseRun objects.

Returns:
(audit_created: bool, verified_created: bool): Tuple of mode status - true for created, false for found
"""

modes = get_edx_course_modes(course_id=run.courseware_id)

found_audit, found_verified = (False, False)

for mode in modes:
if mode.mode_slug == EDX_ENROLLMENT_AUDIT_MODE:
found_audit = True

if mode.mode_slug == EDX_ENROLLMENT_VERIFIED_MODE:
found_verified = True

if not found_audit:
create_edx_course_mode(
course_id=run.courseware_id,
mode_slug=EDX_ENROLLMENT_AUDIT_MODE,
mode_display_name="Audit",
description="Audit",
expiration_datetime=None,
currency="USD",
)

if not found_verified:
create_edx_course_mode(
course_id=run.courseware_id,
mode_slug=EDX_ENROLLMENT_VERIFIED_MODE,
mode_display_name="Verified",
description="Verified",
currency="USD",
min_price=10,
expiration_datetime=run.upgrade_deadline if run.upgrade_deadline else None,
)

# these are created flags, not found flags
return (not found_audit, not found_verified)


def sync_course_mode(runs: list[CourseRun]) -> list[int]:
"""
Sync the course runs' upgrade deadline with the expiration date in its verified mode.

Args:
runs ([CourseRun]): list of CourseRun objects.

Returns:
[str], [str]: Lists of success and error logs respectively
[int, int]: Count of successful and failed operations
"""
api_client = get_edx_api_course_mode_client()

Expand All @@ -637,7 +689,7 @@ def sync_course_mode(runs: list[CourseRun]) -> list[str]:
# Iterate all eligible runs and sync if possible
for run in runs:
try:
course_modes = api_client.get_mode(
course_modes = api_client.get_course_modes(
course_id=run.courseware_id,
)
except HTTPError as e: # noqa: PERF203
Expand All @@ -655,7 +707,7 @@ def sync_course_mode(runs: list[CourseRun]) -> list[str]:
else:
for course_mode in course_modes:
if (
course_mode.mode_slug == "verified"
course_mode.mode_slug == EDX_ENROLLMENT_VERIFIED_MODE
and run.upgrade_deadline != course_mode.expiration_datetime
):
run.upgrade_deadline = course_mode.expiration_datetime
Expand All @@ -671,7 +723,7 @@ def sync_course_mode(runs: list[CourseRun]) -> list[str]:
log.error("%s: %s", str(e), run.courseware_id) # noqa: TRY400
failure_count += 1

return success_count, failure_count
return [success_count, failure_count]


def is_program_text_id(item_text_id):
Expand Down
86 changes: 85 additions & 1 deletion courses/api_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
OrganizationPageFactory,
)
from courses.api import (
check_course_modes,
create_program_enrollments,
create_run_enrollments,
deactivate_run_enrollment,
Expand Down Expand Up @@ -848,7 +849,9 @@ def test_sync_course_mode(settings, mocker, mocked_api_response, expect_success)
responding with an error.
"""
settings.OPENEDX_SERVICE_WORKER_API_TOKEN = "mock_api_token" # noqa: S105
mocker.patch.object(CourseModes, "get_mode", side_effect=[mocked_api_response])
mocker.patch.object(
CourseModes, "get_course_modes", side_effect=[mocked_api_response]
)
course_run = CourseRunFactory.create()

success_count, failure_count = sync_course_mode([course_run])
Expand Down Expand Up @@ -1847,3 +1850,84 @@ def test_b2b_re_enrollment_after_multiple_unenrollments(mocker, user):

enrollment.refresh_from_db()
assert enrollment.active is True


@pytest.mark.parametrize(
"audit_exists",
[
True,
False,
],
)
@pytest.mark.parametrize(
"verified_exists",
[
True,
False,
],
)
def test_check_course_modes(mocker, audit_exists, verified_exists):
"""Test that the course mode check function works properly."""

run = CourseRunFactory.create()

return_modes = []
audit_mode = CourseMode(
{
"course_id": run.courseware_id,
"mode_slug": "audit",
"mode_display_name": "Audit",
}
)
verified_mode = CourseMode(
{
"course_id": run.courseware_id,
"mode_slug": "verified",
"mode_display_name": "Verified",
}
)

if audit_exists:
return_modes.append(audit_mode)

if verified_exists:
return_modes.append(verified_mode)

mocked_get_modes = mocker.patch(
"edx_api.course_detail.CourseModes.get_course_modes", return_value=return_modes
)

mocked_create_mode = mocker.patch(
"edx_api.course_detail.CourseModes.create_course_mode",
side_effect=lambda mode_slug, *args, **kwargs: audit_mode # noqa: ARG005
if mode_slug == "audit"
else verified_mode,
)

audit_created, verified_created = check_course_modes(run)

mocked_get_modes.assert_called()
assert audit_created != audit_exists
assert verified_created != verified_exists

if not audit_exists:
mocked_create_mode.assert_any_call(
course_id=run.courseware_id,
mode_slug="audit",
mode_display_name="Audit",
description="Audit",
currency="USD",
expiration_datetime=None,
min_price=0,
)

if not verified_exists:
mocked_create_mode.assert_any_call(
course_id=run.courseware_id,
mode_slug="verified",
mode_display_name="Verified",
description="Verified",
currency="USD",
expiration_datetime=str(run.upgrade_deadline),
min_price=10,
)
2 changes: 1 addition & 1 deletion frontend/public/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
"@json-editor/json-editor": "2.15.2",
"@material/layout-grid": "0.41.0",
"@material/top-app-bar": "1.1.1",
"@sentry/browser": "8.55.0",
"@sentry/browser": "10.19.0",
"@trust/webcrypto": "0.9.2",
"@types/react": "18.3.20",
"autoprefixer": "9.8.8",
Expand Down
2 changes: 1 addition & 1 deletion main/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@
from main.sentry import init_sentry
from openapi.settings_spectacular import open_spectacular_settings

VERSION = "0.131.3"
VERSION = "0.131.4"

log = logging.getLogger()

Expand Down
112 changes: 112 additions & 0 deletions openedx/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from django.db import transaction
from django.shortcuts import reverse
from edx_api.client import EdxApi
from edx_api.course_detail.models import CourseMode
from edx_api.course_runs.exceptions import CourseRunAPIError
from edx_api.course_runs.models import CourseRun, CourseRunList
from mitol.common.utils import (
Expand Down Expand Up @@ -1514,6 +1515,8 @@ def process_course_run_clone(target_id: int, *, base_id: int | str | None = None
Returns:
bool, whether or not it worked
"""
from courses.api import check_course_modes

edx_client = get_edx_api_jwt_client(
settings.OPENEDX_COURSES_SERVICE_WORKER_CLIENT_ID,
settings.OPENEDX_COURSES_SERVICE_WORKER_CLIENT_SECRET,
Expand Down Expand Up @@ -1580,6 +1583,9 @@ def process_course_run_clone(target_id: int, *, base_id: int | str | None = None
client=edx_client,
)

# Ensure the new course has the proper modes in it.
check_course_modes(target_course)

# Set the ingestion flag on the course run to True
# All B2B courses should be flagged for content file ingestion - we can
# toggle it off manually if necessary.
Expand All @@ -1592,3 +1598,109 @@ def process_course_run_clone(target_id: int, *, base_id: int | str | None = None
"Warning: processed course run clone for %s but can't set the ingestion flag because there's no CoursePage",
target_course,
)


def get_edx_course_modes(
course_id: str, *, client: EdxApi | None = None
) -> list[CourseMode]:
"""
Get the current modes for a given course.

Args:
- course_id (str): the readable ID of the course to use as the base
Keyword Args:
- client (EdxApi): edX client (if you want to reuse one)
Returns:
- list(edx_api.course_detail.models.CourseMode): the modes configured for the course
"""

edx_client = client if client else get_edx_api_service_client()

return edx_client.course_mode.get_course_modes(course_id=course_id)


def get_edx_course_mode(
course_id: str, mode_slug: str, *, client: EdxApi | None = None
) -> CourseMode:
"""
Get the specified mode for a given course.

Args:
- course_id: the readable ID of the course to use as the base
- mode_slug: the slug of the mode you want to retrieve (audit, verified, etc)
Keyword Args:
- client (EdxApi): edX client (if you want to reuse one)
Returns:
- list(edx_api.course_detail.models.CourseMode): the modes configured for the course
"""

edx_client = client if client else get_edx_api_service_client()

return edx_client.course_mode.get_course_mode(
course_id=course_id, mode_slug=mode_slug
)


def create_edx_course_mode( # noqa: PLR0913
course_id: str,
mode_slug: str,
mode_display_name: str,
*,
description: str = "",
currency: str = "USD",
expiration_datetime: datetime | None = None,
client: EdxApi | None = None,
min_price: int = 0,
) -> CourseMode:
"""Create a course mode for the given edX course."""

edx_client = client if client else get_edx_api_service_client()

return edx_client.course_mode.create_course_mode(
course_id=course_id,
mode_slug=mode_slug,
mode_display_name=mode_display_name,
description=description,
currency=currency,
expiration_datetime=str(expiration_datetime) if expiration_datetime else None,
min_price=min_price,
)


def update_edx_course_mode( # noqa: PLR0913
course_id: str,
mode_slug: str,
mode_display_name: str,
*,
description: str = "",
currency: str = "USD",
expiration_datetime: datetime | None = None,
client: EdxApi | None = None,
min_price: int = 0,
) -> CourseMode:
"""Create a course mode for the given edX course."""

edx_client = client if client else get_edx_api_service_client()

return edx_client.course_mode.update_course_mode(
course_id=course_id,
mode_slug=mode_slug,
mode_display_name=mode_display_name,
description=description,
currency=currency,
expiration_datetime=str(expiration_datetime) if expiration_datetime else None,
min_price=min_price,
)


def delete_edx_course_mode(
course_id: str, mode_slug: str, *, client: EdxApi | None = None
):
"""Delete the specified course mode from the edX course."""

edx_client = client if client else get_edx_api_service_client()

return edx_client.course_mode.delete_course_mode(
course_id=course_id,
mode_slug=mode_slug,
)
Loading