From 0a3f14cce9404b38df8dbca2cbb817d6cb81c8c6 Mon Sep 17 00:00:00 2001 From: Rachel Lougee Date: Mon, 4 May 2026 15:55:24 -0400 Subject: [PATCH 1/5] feat: add program certificate migration to migrate_edx_data management command (#3545) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .../management/commands/migrate_edx_data.py | 134 +++++++++++++++++- 1 file changed, 128 insertions(+), 6 deletions(-) diff --git a/courses/management/commands/migrate_edx_data.py b/courses/management/commands/migrate_edx_data.py index ed8967b51e..b259ef9789 100644 --- a/courses/management/commands/migrate_edx_data.py +++ b/courses/management/commands/migrate_edx_data.py @@ -16,11 +16,13 @@ CourseRunGrade, Department, Program, + ProgramCertificate, ProgramEnrollment, ) from ecommerce.api import fulfill_completed_order from ecommerce.constants import ZERO_PAYMENT_DATA from ecommerce.models import PendingOrder, Product +from openedx.constants import EDX_ENROLLMENT_VERIFIED_MODE from users.models import GENDER_CHOICES, LegalAddress, User, UserProfile @@ -428,7 +430,7 @@ def _bulk_create_certificates(rows, batch_size): ) return len(new_certificate_objects) - def _migrate_certificates(self, conn, options): + def _migrate_course_certificates(self, conn, options): """ Migrate certificates from edX to MITx Online. Create CourseRunEnrollment, CourseRunGrade, and CourseRunCertificate instances. @@ -498,6 +500,114 @@ def _migrate_certificates(self, conn, options): ) ) + @staticmethod + def _bulk_create_program_enrollments(rows, batch_size): + user_ids = {row["user_mitxonline_id"] for row in rows} + program_ids = {row["program_id"] for row in rows} + existing = set( + ProgramEnrollment.all_objects.filter( + user_id__in=user_ids, + program_id__in=program_ids, + ).values_list("user_id", "program_id") + ) + new_enrollment_objects = [ + ProgramEnrollment( + user_id=row["user_mitxonline_id"], + program_id=row["program_id"], + enrollment_mode=EDX_ENROLLMENT_VERIFIED_MODE, + ) + for row in rows + if (row["user_mitxonline_id"], row["program_id"]) not in existing + ] + if not new_enrollment_objects: + return 0 + + ProgramEnrollment.all_objects.bulk_create( + new_enrollment_objects, + batch_size=batch_size, + ignore_conflicts=True, + ) + return len(new_enrollment_objects) + + @staticmethod + def _bulk_create_program_certificates(rows, batch_size): + user_ids = {row["user_mitxonline_id"] for row in rows} + program_ids = {row["program_id"] for row in rows} + existing = set( + ProgramCertificate.all_objects.filter( + user_id__in=user_ids, + program_id__in=program_ids, + ).values_list("user_id", "program_id") + ) + new_certificate_objects = [ + ProgramCertificate( + user_id=row["user_mitxonline_id"], + program_id=row["program_id"], + issue_date=row["program_certificate_issued_on"], + certificate_page_revision_id=row["certificate_page_revision_id"], + ) + for row in rows + if (row["user_mitxonline_id"], row["program_id"]) not in existing + ] + if not new_certificate_objects: + return 0 + + ProgramCertificate.objects.bulk_create( + new_certificate_objects, + batch_size=batch_size, + ignore_conflicts=True, + ) + return len(new_certificate_objects) + + def _migrate_program_certificates(self, conn, options): + """ + Migrate program certificates from edX to MITx Online. Create ProgramEnrollment + and ProgramCertificate instances. + """ + limit = options.get("limit") + batch_size = options.get("batch_size", 1000) + + cur = conn.cursor() + + query = ( + "SELECT * FROM edxorg_to_mitxonline_program_certificates " + "WHERE user_mitxonline_id IS NOT NULL AND program_id IS NOT NULL" + ) + + if limit is not None: + query += f" LIMIT {int(limit)}" + + cur.execute(query) + columns = [desc[0] for desc in cur.description] + + total_enrollments = 0 + total_certificates = 0 + + while True: + results = cur.fetchmany(batch_size) + if not results: + break + + rows = [dict(zip(columns, r)) for r in results] + + total_enrollments += self._bulk_create_program_enrollments(rows, batch_size) + total_certificates += self._bulk_create_program_certificates( + rows, batch_size + ) + self.stdout.write( + self.style.SUCCESS( + f"{total_enrollments} program enrollments created, " + f"{total_certificates} program certificates created" + ) + ) + + self.stdout.write( + self.style.SUCCESS( + f"Program certificate migration complete: " + f"{total_enrollments} enrollments, {total_certificates} certificates" + ) + ) + def _migrate_entitlements(self, conn, options): """ Migrate entitlement from edX to MITx Online. Create program Order instances. @@ -621,9 +731,15 @@ def add_arguments(self, parser) -> None: ) parser.add_argument( "--type", - choices=["course_runs", "users", "certificates", "entitlements"], + choices=[ + "course_runs", + "users", + "course_certificates", + "program_certificates", + "entitlements", + ], default="course_runs", - help="Choose which migration to run: course_runs, users (default: course_runs)", + help="Choose which migration to run: course_runs, users, course_certificates, program_certificates, entitlements (default: course_runs)", ) parser.add_argument("--dry-run", action="store_true") parser.add_argument( @@ -645,11 +761,17 @@ def handle(self, *args, **options): # pylint: disable=unused-argument # noqa: A self.stdout.write("Migrating the edX users ...") self._migrate_users(conn, options) - if migrate_type == "certificates": + if migrate_type == "course_certificates": + self.stdout.write( + "Migrating the edX course enrollments, grades and certificates ..." + ) + self._migrate_course_certificates(conn, options) + + if migrate_type == "program_certificates": self.stdout.write( - "Migrating the edX enrollments, grades and certificates ..." + "Migrating the edX program enrollments and certificates ..." ) - self._migrate_certificates(conn, options) + self._migrate_program_certificates(conn, options) if migrate_type == "entitlements": self.stdout.write( From 7cfff1cf2d3ce4b434c315fe66010fcafd5a78f2 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 4 May 2026 21:31:01 -0400 Subject: [PATCH 2/5] Update dependency postcss to v8.5.10 [SECURITY] (#3543) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- frontend/public/package.json | 2 +- yarn.lock | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/frontend/public/package.json b/frontend/public/package.json index 0364739364..ec3b433e3f 100644 --- a/frontend/public/package.json +++ b/frontend/public/package.json @@ -70,7 +70,7 @@ "nyc": "15.1.0", "object.entries": "1.1.9", "popper.js": "1.16.1", - "postcss": "8.5.6", + "postcss": "8.5.10", "postcss-loader": "6.2.1", "posthog-js": "^1.75.4", "prettier-eslint": "^16.3.0", diff --git a/yarn.lock b/yarn.lock index ce28d87eef..b8740c8921 100644 --- a/yarn.lock +++ b/yarn.lock @@ -15870,7 +15870,7 @@ __metadata: nyc: 15.1.0 object.entries: 1.1.9 popper.js: 1.16.1 - postcss: 8.5.6 + postcss: 8.5.10 postcss-loader: 6.2.1 posthog-js: ^1.75.4 prettier-eslint: ^16.3.0 @@ -18119,14 +18119,14 @@ __metadata: languageName: node linkType: hard -"postcss@npm:8.5.6": - version: 8.5.6 - resolution: "postcss@npm:8.5.6" +"postcss@npm:8.5.10": + version: 8.5.10 + resolution: "postcss@npm:8.5.10" dependencies: nanoid: ^3.3.11 picocolors: ^1.1.1 source-map-js: ^1.2.1 - checksum: 20f3b5d673ffeec2b28d65436756d31ee33f65b0a8bedb3d32f556fbd5973be38c3a7fb5b959a5236c60a5db7b91b0a6b14ffaac0d717dce1b903b964ee1c1bb + checksum: 9af9cd7f2f0d4b8456f6710e48d586328433509b695911fda942c24ac4db4e62c6fed8c6c6d8c8258326285f669494c2c36a4ff84aa160f0586eb545e5258bf5 languageName: node linkType: hard From a9698cf6bc6d846d5746349abc597b01d74271ea Mon Sep 17 00:00:00 2001 From: Muhammad Arslan Date: Tue, 5 May 2026 13:27:44 +0500 Subject: [PATCH 3/5] fix: if user is not sync'd with edX yet, don't raise (#3499) --- openedx/api.py | 45 ++++++++++++++++++++++++++---------- openedx/api_test.py | 56 ++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 88 insertions(+), 13 deletions(-) diff --git a/openedx/api.py b/openedx/api.py index aa72c60203..9b8f6cdc0e 100644 --- a/openedx/api.py +++ b/openedx/api.py @@ -407,6 +407,20 @@ def _create_edx_user_request(open_edx_user, user, access_token): # noqa: C901, lock.release() +def _edx_user_exists(user): + """ + Returns True if the user can be verified to exist in edX, False otherwise. + """ + try: + client = get_edx_api_client(user) + if client is None: + return False + client.user_info.get_user_info() + except: # noqa: E722 + return False + return True + + def create_edx_user(user, edx_username=None): """ Makes a request to create an equivalent user in Open edX @@ -442,17 +456,9 @@ def create_edx_user(user, edx_username=None): log.warning("create_edx_user: skipping create for %s, no edx_username", user) return False - if open_edx_user.has_been_synced: - # Here we should check with edx that the user exists on that end. - try: - client = get_edx_api_client(user) - client.user_info.get_user_info() - except: # noqa: S110, E722 - pass - else: - open_edx_user.has_been_synced = True - open_edx_user.save() - return False + if open_edx_user.has_been_synced and _edx_user_exists(user): + # User already exists in edX, skip creation + return False try: return _create_edx_user_request(open_edx_user, user, access_token) @@ -857,7 +863,14 @@ def get_edx_api_client(user, ttl_in_seconds=OPENEDX_AUTH_DEFAULT_TTL_IN_SECONDS) # has an OpenEdxApiAuth record before we try to read it. It is idempotent: internally # it uses get_or_create, so calling it on every request is safe and causes no side # effects when the record already exists. - create_edx_auth_token(user) + if create_edx_auth_token(user) is None: + if settings.FEATURES.get(features.IGNORE_EDX_FAILURES, False): + log.warning( + "get_edx_api_client: user %s is not yet synced with edX, skipping", user + ) + return None + raise NoEdxApiAuthError(f"{user!s} is not yet synced with edX") # noqa: EM102 + try: auth = get_valid_edx_api_auth(user, ttl_in_seconds=ttl_in_seconds) except OpenEdxApiAuth.DoesNotExist: @@ -1284,6 +1297,8 @@ def update_edx_user_name(user): UserNameUpdateFailedException: Raised if underlying edX API request fails due to any reason """ edx_client = get_edx_api_client(user) + if edx_client is None: + return None try: return edx_client.user_info.update_user_name(user.edx_username, user.name) except Exception as exc: # noqa: BLE001 @@ -1298,6 +1313,8 @@ def sync_enrollments_with_edx( ) -> SyncResult[courses.models.CourseRunEnrollment]: """Syncs enrollment records so that local enrollments match the enrollment data in edX""" client = get_edx_api_client(user) + if client is None: + return SyncResult() edx_enrollments = client.enrollments.get_student_enrollments() local_enrollments = ( user.courserunenrollment_set(manager="all_objects") @@ -1374,6 +1391,8 @@ def subscribe_to_edx_course_emails(user, course_run): EdxApiChangeEmailSettingsException: Raised if an unknown error was encountered during the edX API request """ edx_client = get_edx_api_client(user) + if edx_client is None: + return None try: result = edx_client.email_settings.subscribe(course_run.courseware_id) except HTTPError as exc: @@ -1400,6 +1419,8 @@ def unsubscribe_from_edx_course_emails(user, course_run): EdxApiChangeEmailSettingsException: Raised if an unknown error was encountered during the edX API request """ edx_client = get_edx_api_client(user) + if edx_client is None: + return None try: result = edx_client.email_settings.unsubscribe(course_run.courseware_id) except HTTPError as exc: diff --git a/openedx/api_test.py b/openedx/api_test.py index 53551f54c5..b573acae91 100644 --- a/openedx/api_test.py +++ b/openedx/api_test.py @@ -71,6 +71,7 @@ EdxApiEnrollErrorException, EdxApiRegistrationValidationException, EdxApiUserUpdateError, + NoEdxApiAuthError, UnknownEdxApiEmailSettingsException, UnknownEdxApiEnrollException, UserNameUpdateFailedException, @@ -862,9 +863,22 @@ def test_get_edx_course_outline_missing_service_token(settings): get_edx_course_outline("course-v1:OpenedX+DemoX+DemoCourse") +def test_get_edx_api_client_not_synced_raises(mocker, user): + """get_edx_api_client raises NoEdxApiAuthError when user is not synced and IGNORE_EDX_FAILURES is False""" + mocker.patch("openedx.api.create_edx_auth_token", return_value=None) + with pytest.raises(NoEdxApiAuthError): + get_edx_api_client(user) + + +def test_get_edx_api_client_not_synced_ignore_failures(mocker, settings, user): + """get_edx_api_client returns None when user is not synced and IGNORE_EDX_FAILURES is True""" + settings.FEATURES = {"IGNORE_EDX_FAILURES": True} + mocker.patch("openedx.api.create_edx_auth_token", return_value=None) + assert get_edx_api_client(user) is None + + def test_get_edx_retirement_service_client(mocker, settings): """Tests that get_edx_retirement_service_client returns an EdxApi client""" - settings.OPENEDX_API_BASE_URL = "http://example.com" settings.OPENEDX_RETIREMENT_SERVICE_WORKER_CLIENT_ID = ( "OPENEDX_RETIREMENT_SERVICE_WORKER_CLIENT_ID" @@ -1237,6 +1251,22 @@ def test_update_edx_user_name_creates_missing_auth(mocker, user): assert result == update_name_return_value +def test_update_edx_user_name_not_synced(mocker, user): + """update_edx_user_name returns None without calling edX when user is not yet synced""" + mocker.patch("openedx.api.get_edx_api_client", return_value=None) + result = update_edx_user_name(user) + assert result is None + + +def test_update_edx_user_name_not_synced_raises(mocker, user): + """update_edx_user_name propagates NoEdxApiAuthError when user is not synced and IGNORE_EDX_FAILURES is False""" + mocker.patch( + "openedx.api.get_edx_api_client", side_effect=NoEdxApiAuthError("not synced") + ) + with pytest.raises(NoEdxApiAuthError): + update_edx_user_name(user) + + def test_sync_enrollments_with_edx_active(mocker, user): """sync_enrollments_with_edx should update the 'active' property of existing enrollment records""" courseware_ids = [ @@ -1327,6 +1357,14 @@ def test_sync_enrollments_with_edx_missing(mocker, user): assert results == SyncResult() +def test_sync_enrollments_with_edx_not_synced(mocker, user): + """sync_enrollments_with_edx returns an empty SyncResult without calling edX when client is None""" + CourseRunEnrollmentFactory.create(user=user, active=True) + mocker.patch("openedx.api.get_edx_api_client", return_value=None) + result = sync_enrollments_with_edx(user) + assert result == SyncResult() + + def test_subscribe_to_edx_course_emails(mocker, user): """Tests that subscribe_to_edx_course_emails makes a call to subscribe for course emails in edX via api client""" mock_client = mocker.MagicMock() @@ -1345,6 +1383,14 @@ def test_subscribe_to_edx_course_emails(mocker, user): assert subscribe_to_course_emails == subscribe_return_value +def test_subscribe_to_edx_course_emails_not_synced(mocker, user): + """subscribe_to_edx_course_emails returns None without calling edX when client is None""" + run_enrollment = CourseRunEnrollmentFactory() + mocker.patch("openedx.api.get_edx_api_client", return_value=None) + result = subscribe_to_edx_course_emails(user, run_enrollment.run) + assert result is None + + @pytest.mark.parametrize( "client_exception_raised, expected_exception", # noqa: PT006 [ @@ -1386,6 +1432,14 @@ def test_unsubscribe_from_edx_course_emails(mocker, user): assert unsubscribe_to_course_emails == unsubscribe_return_value +def test_unsubscribe_from_edx_course_emails_not_synced(mocker, user): + """unsubscribe_from_edx_course_emails returns None without calling edX when client is None""" + run_enrollment = CourseRunEnrollmentFactory() + mocker.patch("openedx.api.get_edx_api_client", return_value=None) + result = unsubscribe_from_edx_course_emails(user, run_enrollment.run) + assert result is None + + @pytest.mark.parametrize( "client_exception_raised, expected_exception", # noqa: PT006 [ From 9e8e1ea95605e3e464c10144ae681d2bc361e9c2 Mon Sep 17 00:00:00 2001 From: Muhammad Anas <88967643+Anas12091101@users.noreply.github.com> Date: Tue, 5 May 2026 18:13:37 +0500 Subject: [PATCH 4/5] fix: make DiscountFactory defaults evaluate per-instance to prevent flaky test_create_basket_with_product (#3532) --- ecommerce/factories.py | 28 ++++++++++++---------------- ecommerce/views/v0/views_test.py | 27 +++++++++++++++++++++------ flexiblepricing/api_test.py | 5 +++-- openedx/api_test.py | 4 +++- 4 files changed, 39 insertions(+), 25 deletions(-) diff --git a/ecommerce/factories.py b/ecommerce/factories.py index 0637834923..99254ed398 100644 --- a/ecommerce/factories.py +++ b/ecommerce/factories.py @@ -1,5 +1,3 @@ -import random - import faker from factory import SubFactory, fuzzy from factory.django import DjangoModelFactory @@ -41,12 +39,10 @@ class Meta: class DiscountFactory(DjangoModelFactory): - amount = random.randrange(1, 50, 1) # noqa: S311 - discount_type = ALL_DISCOUNT_TYPES[random.randrange(0, len(ALL_DISCOUNT_TYPES), 1)] # noqa: S311 + amount = fuzzy.FuzzyInteger(1, 49) + discount_type = fuzzy.FuzzyChoice(ALL_DISCOUNT_TYPES) discount_code = fuzzy.FuzzyText(length=20) - redemption_type = ALL_REDEMPTION_TYPES[ - random.randrange(0, len(ALL_REDEMPTION_TYPES), 1) # noqa: S311 - ] + redemption_type = fuzzy.FuzzyChoice(ALL_REDEMPTION_TYPES) payment_type = None class Meta: @@ -57,8 +53,8 @@ class Meta: class OneTimeDiscountFactory(DjangoModelFactory): - amount = random.randrange(1, 50, 1) # noqa: S311 - discount_type = ALL_DISCOUNT_TYPES[random.randrange(0, len(ALL_DISCOUNT_TYPES), 1)] # noqa: S311 + amount = fuzzy.FuzzyInteger(1, 49) + discount_type = fuzzy.FuzzyChoice(ALL_DISCOUNT_TYPES) discount_code = fuzzy.FuzzyText(length=20) redemption_type = REDEMPTION_TYPE_ONE_TIME @@ -67,8 +63,8 @@ class Meta: class OneTimePerUserDiscountFactory(DjangoModelFactory): - amount = random.randrange(1, 50, 1) # noqa: S311 - discount_type = ALL_DISCOUNT_TYPES[random.randrange(0, len(ALL_DISCOUNT_TYPES), 1)] # noqa: S311 + amount = fuzzy.FuzzyInteger(1, 49) + discount_type = fuzzy.FuzzyChoice(ALL_DISCOUNT_TYPES) discount_code = fuzzy.FuzzyText(length=20) redemption_type = REDEMPTION_TYPE_ONE_TIME_PER_USER @@ -77,8 +73,8 @@ class Meta: class UnlimitedUseDiscountFactory(DjangoModelFactory): - amount = random.randrange(1, 50, 1) # noqa: S311 - discount_type = ALL_DISCOUNT_TYPES[random.randrange(0, len(ALL_DISCOUNT_TYPES), 1)] # noqa: S311 + amount = fuzzy.FuzzyInteger(1, 49) + discount_type = fuzzy.FuzzyChoice(ALL_DISCOUNT_TYPES) discount_code = fuzzy.FuzzyText(length=20) redemption_type = REDEMPTION_TYPE_UNLIMITED @@ -87,11 +83,11 @@ class Meta: class SetLimitDiscountFactory(DjangoModelFactory): - amount = random.randrange(1, 50, 1) # noqa: S311 - discount_type = ALL_DISCOUNT_TYPES[random.randrange(0, len(ALL_DISCOUNT_TYPES), 1)] # noqa: S311 + amount = fuzzy.FuzzyInteger(1, 49) + discount_type = fuzzy.FuzzyChoice(ALL_DISCOUNT_TYPES) discount_code = fuzzy.FuzzyText(length=20) redemption_type = REDEMPTION_TYPE_UNLIMITED - max_redemptions = random.randrange(1, 5, 1) # noqa: S311 + max_redemptions = fuzzy.FuzzyInteger(1, 4) class Meta: model = models.Discount diff --git a/ecommerce/views/v0/views_test.py b/ecommerce/views/v0/views_test.py index c0b44018e8..9abbbc5ae2 100644 --- a/ecommerce/views/v0/views_test.py +++ b/ecommerce/views/v0/views_test.py @@ -30,6 +30,7 @@ PAYMENT_TYPE_CUSTOMER_SUPPORT, PAYMENT_TYPE_FINANCIAL_ASSISTANCE, REDEMPTION_TYPE_ONE_TIME, + REDEMPTION_TYPE_UNLIMITED, ZERO_PAYMENT_DATA, ) from ecommerce.discounts import DiscountType @@ -409,7 +410,10 @@ def test_create_basket_with_product( # noqa: PLR0913 ): """Test creating a basket with a single product, and/or a discount.""" - product = ProductFactory.create() + # Use a fixed price well above any discount amount so that the discount + # comparison logic works correctly regardless of discount type (percent-off, + # dollars-off, or fixed-price). + product = ProductFactory.create(price=Decimal("1000.00")) basket = BasketFactory(user=user) if existing_basket else None @@ -420,12 +424,16 @@ def test_create_basket_with_product( # noqa: PLR0913 if add_discount: if existing_discount in ["better", "worse"]: - # Create and apply a discount that is either better or worse than - # the one that'll be "supplied" below. + # Create and apply a percent-off discount that is either better or + # worse than the supplied 50% discount below. "better" = 60% off + # (lower final price), "worse" = 10% off (higher final price). + # redemption_type is set explicitly to avoid import-time randomness + # from DiscountFactory affecting discount validity checks. ex_discount = DiscountFactory( - discount_type="percent-off", + discount_type=DISCOUNT_TYPE_PERCENT_OFF, amount=10 if existing_discount == "worse" else 60, + redemption_type=REDEMPTION_TYPE_UNLIMITED, ) BasketDiscount.objects.create( redeemed_basket=basket, @@ -436,7 +444,10 @@ def test_create_basket_with_product( # noqa: PLR0913 if bad_discount: discount = DiscountFactory( - discount_type="percent-off", amount=50, max_redemptions=1 + discount_type=DISCOUNT_TYPE_PERCENT_OFF, + amount=50, + max_redemptions=1, + redemption_type=REDEMPTION_TYPE_UNLIMITED, ) order = OrderFactory.create() DiscountRedemption.objects.create( @@ -446,7 +457,11 @@ def test_create_basket_with_product( # noqa: PLR0913 redeemed_order=order, ) else: - discount = DiscountFactory(discount_type="percent-off", amount=50) + discount = DiscountFactory( + discount_type=DISCOUNT_TYPE_PERCENT_OFF, + amount=50, + redemption_type=REDEMPTION_TYPE_UNLIMITED, + ) url = reverse( "v0:baskets_api-create_from_product_with_discount", diff --git a/flexiblepricing/api_test.py b/flexiblepricing/api_test.py index 9ffdf7db64..6c7c55c08c 100644 --- a/flexiblepricing/api_test.py +++ b/flexiblepricing/api_test.py @@ -445,6 +445,8 @@ def create_fp_and_compare_tiers( else ContentType.objects.get(app_label="courses", model="course") ) + courseware_tier = determine_tier_courseware(courseware_object, income_usd) + if not FlexiblePrice.objects.filter( user=user, courseware_content_type=content_type, @@ -455,12 +457,11 @@ def create_fp_and_compare_tiers( country_of_income=country_code, user=user, courseware_object=courseware_object, + tier=courseware_tier, status=FlexiblePriceStatus.APPROVED if expected else FlexiblePriceStatus.PENDING_MANUAL_APPROVAL, ) - - courseware_tier = determine_tier_courseware(courseware_object, income_usd) discount = self.create_run_and_product_and_discount(user, courseware_object) return courseware_tier, discount diff --git a/openedx/api_test.py b/openedx/api_test.py index b573acae91..70053fefb6 100644 --- a/openedx/api_test.py +++ b/openedx/api_test.py @@ -2,6 +2,7 @@ # pylint: disable=redefined-outer-name import itertools +import logging from datetime import timedelta from unittest.mock import ANY, call, patch from urllib.parse import parse_qsl @@ -1621,7 +1622,8 @@ def test_update_edx_user_profile_no_openedx_user( Test that update_edx_user_profile does not attempt to update the user profile in Open edX when Open edX user is not synced """ user.openedx_users.all().delete() - update_edx_user_profile(user) + with caplog.at_level(logging.INFO): + update_edx_user_profile(user) assert "Skipping user profile update" in caplog.text From 3f91476c28950a81010c4779349aae36acc99dd0 Mon Sep 17 00:00:00 2001 From: Doof Date: Tue, 5 May 2026 13:32:08 +0000 Subject: [PATCH 5/5] Release 1.149.5 --- RELEASE.rst | 8 ++++++++ main/settings.py | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/RELEASE.rst b/RELEASE.rst index 311f64ee8f..ad077a9679 100644 --- a/RELEASE.rst +++ b/RELEASE.rst @@ -1,6 +1,14 @@ Release Notes ============= +Version 1.149.5 +--------------- + +- fix: make DiscountFactory defaults evaluate per-instance to prevent flaky test_create_basket_with_product (#3532) +- fix: if user is not sync'd with edX yet, don't raise (#3499) +- Update dependency postcss to v8.5.10 [SECURITY] (#3543) +- feat: add program certificate migration to migrate_edx_data management command (#3545) + Version 1.149.4 (Released May 04, 2026) --------------- diff --git a/main/settings.py b/main/settings.py index 80e9832584..ea8f68681e 100644 --- a/main/settings.py +++ b/main/settings.py @@ -37,7 +37,7 @@ from main.sentry import init_sentry from openapi.settings_spectacular import open_spectacular_settings -VERSION = "1.149.4" +VERSION = "1.149.5" log = logging.getLogger()