diff --git a/RELEASE.rst b/RELEASE.rst index 348e2fe56b..1fe129c39e 100644 --- a/RELEASE.rst +++ b/RELEASE.rst @@ -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) --------------- diff --git a/courses/api.py b/courses/api.py index 488244466d..6510022b66 100644 --- a/courses/api.py +++ b/courses/api.py @@ -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, ) @@ -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() @@ -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 @@ -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 @@ -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): diff --git a/courses/api_test.py b/courses/api_test.py index f9166686fc..7f4b3d9b1c 100644 --- a/courses/api_test.py +++ b/courses/api_test.py @@ -24,6 +24,7 @@ OrganizationPageFactory, ) from courses.api import ( + check_course_modes, create_program_enrollments, create_run_enrollments, deactivate_run_enrollment, @@ -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]) @@ -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, + ) diff --git a/frontend/public/package.json b/frontend/public/package.json index 496b8ae410..437879134b 100644 --- a/frontend/public/package.json +++ b/frontend/public/package.json @@ -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", diff --git a/main/settings.py b/main/settings.py index 9e6f36d5f8..56f962d386 100644 --- a/main/settings.py +++ b/main/settings.py @@ -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() diff --git a/openedx/api.py b/openedx/api.py index b152e837f8..cf3300c955 100644 --- a/openedx/api.py +++ b/openedx/api.py @@ -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 ( @@ -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, @@ -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. @@ -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, + ) diff --git a/poetry.lock b/poetry.lock index e40e93d348..3493e0cad5 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1651,14 +1651,14 @@ sidecar = ["drf-spectacular-sidecar"] [[package]] name = "edx-api-client" -version = "1.12.0" +version = "1.13.0" description = "Python interface to the edX REST APIs" optional = false python-versions = "*" groups = ["main"] files = [ - {file = "edx_api_client-1.12.0-py2.py3-none-any.whl", hash = "sha256:4d34e2fa09ede004d1232ee04c9bc0a5135533da9ffaa8d4bf3d62ebfa40eb85"}, - {file = "edx_api_client-1.12.0.tar.gz", hash = "sha256:982dd89ba6ef83f9b4abd0d3beaf4d68c31206afe5d0d0fd10f767caa16be602"}, + {file = "edx_api_client-1.13.0-py2.py3-none-any.whl", hash = "sha256:60a949eb31ddf3f8b0ddb63189d58bc9e7057117dfad032c9416a1ff1a401fae"}, + {file = "edx_api_client-1.13.0.tar.gz", hash = "sha256:d52f3267e0af0d4bb60982d31fd792c4edf10bb935ab0389c7b47c9dd8737e27"}, ] [package.dependencies] @@ -6062,4 +6062,4 @@ cffi = ["cffi (>=1.17,<2.0) ; platform_python_implementation != \"PyPy\" and pyt [metadata] lock-version = "2.1" python-versions = "^3.10" -content-hash = "a591f2e9ed75bfbbe0892a19d588f951f14a5e3d262dc77d97377f7c207e51bc" +content-hash = "d881351db89fcfa47912ffb637a70e6e9c1226dcb240cb873b2d439f85be98f1" diff --git a/pyproject.toml b/pyproject.toml index 871633b6e1..817e5d4095 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,7 +42,7 @@ djangorestframework = "^3.12.4" djoser = "^2.1.0" drf-extensions = "^0.7.1" drf-spectacular = "^0.28.0" -edx-api-client = "^1.12.0" +edx-api-client = "^1.13.0" hubspot-api-client = "^6.1.0" hypothesis = "4.23.9" ipython = "^8.0.0" diff --git a/pytest.ini b/pytest.ini index ddaa8c1560..599f8a5198 100644 --- a/pytest.ini +++ b/pytest.ini @@ -28,6 +28,7 @@ env = OPENEDX_API_BASE_URL=http://localhost:18000 OPENEDX_API_CLIENT_ID=fake_client_id OPENEDX_API_CLIENT_SECRET=fake_client_secret + OPENEDX_SERVICE_WORKER_API_TOKEN=fake_service_worker_token SENTRY_DSN= RECAPTCHA_SITE_KEY= RECAPTCHA_SECRET_KEY= diff --git a/yarn.lock b/yarn.lock index ea57c189f4..0459d975f2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3570,61 +3570,61 @@ __metadata: languageName: node linkType: hard -"@sentry-internal/browser-utils@npm:8.55.0": - version: 8.55.0 - resolution: "@sentry-internal/browser-utils@npm:8.55.0" +"@sentry-internal/browser-utils@npm:10.19.0": + version: 10.19.0 + resolution: "@sentry-internal/browser-utils@npm:10.19.0" dependencies: - "@sentry/core": 8.55.0 - checksum: 813d36dc70bd25ee3903c812610f06187eb3bd7bb76160e75b490697a60435a34cd6b13fe358a7273153504d673e80c4263acc26f66b6b9a8eaa06a5dc73052c + "@sentry/core": 10.19.0 + checksum: f563c96f9e4d7b1766028252efe2c690e4c3330d1eb2d70eca26b60baea81b0198dc8710d6bd710d33aa45a584bfb20edd2a133f390fabef2a4c1be9789c2d32 languageName: node linkType: hard -"@sentry-internal/feedback@npm:8.55.0": - version: 8.55.0 - resolution: "@sentry-internal/feedback@npm:8.55.0" +"@sentry-internal/feedback@npm:10.19.0": + version: 10.19.0 + resolution: "@sentry-internal/feedback@npm:10.19.0" dependencies: - "@sentry/core": 8.55.0 - checksum: 0f079debae0efdcf7e596cf20defd5dea5ff86e87f5e18946adca878c6a3fca8bc9ef885959e9ca5f2ca9cfb200374f2492394284ae736d3f95c994170888a79 + "@sentry/core": 10.19.0 + checksum: da6bb6fb51261dec281c50e78b776906cc7bc0e5902bb5ffb91a8d9d83a56e870cdff07da437bdcbf6406a4b7182929a4433dfd06089fc81fcb401a2ff4e0520 languageName: node linkType: hard -"@sentry-internal/replay-canvas@npm:8.55.0": - version: 8.55.0 - resolution: "@sentry-internal/replay-canvas@npm:8.55.0" +"@sentry-internal/replay-canvas@npm:10.19.0": + version: 10.19.0 + resolution: "@sentry-internal/replay-canvas@npm:10.19.0" dependencies: - "@sentry-internal/replay": 8.55.0 - "@sentry/core": 8.55.0 - checksum: 5fb270fa99a62c5497724e87b3f49b8333f03b9d53cf63b4f0156e95c7650323cae37888ebca28632127ae5c22fd14008bb80e73953c0d68a0667b595fb94b82 + "@sentry-internal/replay": 10.19.0 + "@sentry/core": 10.19.0 + checksum: cb2857093aaaf86d6a86bb754c97bae97bd081af5257e961f47f0ea92114d68cd78075ee59e86d3c11d6992952a2f97cad7cf37f2a48043491b9b7c0e9616b34 languageName: node linkType: hard -"@sentry-internal/replay@npm:8.55.0": - version: 8.55.0 - resolution: "@sentry-internal/replay@npm:8.55.0" +"@sentry-internal/replay@npm:10.19.0": + version: 10.19.0 + resolution: "@sentry-internal/replay@npm:10.19.0" dependencies: - "@sentry-internal/browser-utils": 8.55.0 - "@sentry/core": 8.55.0 - checksum: 8328aa2caf51ad7f7dbdfd527f78bd047dc62ca79a3380eceb781c6d848a3395118645c7df3245cc9389e0dd850b38a98f8341561e411425f843f053df84230e + "@sentry-internal/browser-utils": 10.19.0 + "@sentry/core": 10.19.0 + checksum: 235e3268e9a23f91098b92f601cf5b8b64064746adcb3f9e927edc2f4649273616f84d41b6d64c73d210dcd3dba8eb54c97eabae348efce176a1b222e3e4503e languageName: node linkType: hard -"@sentry/browser@npm:8.55.0": - version: 8.55.0 - resolution: "@sentry/browser@npm:8.55.0" +"@sentry/browser@npm:10.19.0": + version: 10.19.0 + resolution: "@sentry/browser@npm:10.19.0" dependencies: - "@sentry-internal/browser-utils": 8.55.0 - "@sentry-internal/feedback": 8.55.0 - "@sentry-internal/replay": 8.55.0 - "@sentry-internal/replay-canvas": 8.55.0 - "@sentry/core": 8.55.0 - checksum: a89b2ca8ecc4d2c1713f1e77360e7a9a1ebaf9e9c8627e54538a7131ab15273e99be5236ce0c46c392d29cdbb7a7cd2ed0e0ff456bae16617b47db9c0b7049b4 + "@sentry-internal/browser-utils": 10.19.0 + "@sentry-internal/feedback": 10.19.0 + "@sentry-internal/replay": 10.19.0 + "@sentry-internal/replay-canvas": 10.19.0 + "@sentry/core": 10.19.0 + checksum: 856dc2720c5882cb579f77a5cb6dd4c8bee2d81a4b526a6b76e2715de7329d03a0f6c8ba02819879173346a6166a35345e28d8ca90fcbc701ed5935932dc8c30 languageName: node linkType: hard -"@sentry/core@npm:8.55.0": - version: 8.55.0 - resolution: "@sentry/core@npm:8.55.0" - checksum: 2600d4e8858d00ba0e3bfe01d8048212add8df9fe9f8ed32af1f08bc0b3fe0b9e8db273e746a1985d95a9668e8bcc838cedbbd315824ce30183a17052ab8c5cc +"@sentry/core@npm:10.19.0": + version: 10.19.0 + resolution: "@sentry/core@npm:10.19.0" + checksum: cd4659f6da8c17797f2fa85ad17cc8cf2f386fe5539d8f769cd7349f1005871f877c09c27902e4ef61e09b6b3f343eb60c724731593965e1aa1056457e7ee9a0 languageName: node linkType: hard @@ -14775,7 +14775,7 @@ __metadata: "@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 "@testing-library/jest-dom": 5.17.0 "@testing-library/react": 11.2.7 "@trust/webcrypto": 0.9.2