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
8 changes: 8 additions & 0 deletions RELEASE.rst
Original file line number Diff line number Diff line change
@@ -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)
---------------

Expand Down
134 changes: 128 additions & 6 deletions courses/management/commands/migrate_edx_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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(
Expand All @@ -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(
Expand Down
28 changes: 12 additions & 16 deletions ecommerce/factories.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import random

import faker
from factory import SubFactory, fuzzy
from factory.django import DjangoModelFactory
Expand Down Expand Up @@ -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:
Expand All @@ -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

Expand All @@ -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

Expand All @@ -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

Expand All @@ -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
Expand Down
27 changes: 21 additions & 6 deletions ecommerce/views/v0/views_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand All @@ -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,
Expand All @@ -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(
Expand All @@ -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",
Expand Down
5 changes: 3 additions & 2 deletions flexiblepricing/api_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion frontend/public/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion main/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
Loading
Loading