From a4a5a7f95d69bbcb7ed608dfaad6d59705181cab Mon Sep 17 00:00:00 2001 From: Dan Subak Date: Tue, 17 Mar 2026 13:10:11 -0400 Subject: [PATCH 1/6] Add criteria field and course-level feature flag to CMS CertificatePage (#3373) --- .env.example | 3 +- app.json | 4 -- ...rovision_verifiable_credential_and_more.py | 30 +++++++++ cms/models.py | 16 ++++- courses/api.py | 65 ++++++++++++------- courses/api_test.py | 36 +++++----- main/features.py | 3 - main/settings.py | 5 -- 8 files changed, 107 insertions(+), 55 deletions(-) create mode 100644 cms/migrations/0057_certificatepage_should_provision_verifiable_credential_and_more.py diff --git a/.env.example b/.env.example index b98da62d0e..9efae1eff5 100644 --- a/.env.example +++ b/.env.example @@ -38,5 +38,4 @@ MITOL_APIGATEWAY_USERINFO_UPDATE=True # Host for Digital Credentials issuer-coordinator service VERIFIABLE_CREDENTIAL_SIGNER_URL=http://dcc.odl.local:4005/instance/test/credentials/issue -# By default, this is controlled by Posthog feature flag and is disabled for locals. To enable, set to True here. -ENABLE_VERIFIABLE_CREDENTIALS_PROVISIONING=False +VERIFIABLE_CREDENTIAL_BEARER_TOKEN='test' diff --git a/app.json b/app.json index 769b62948f..462723dc4c 100644 --- a/app.json +++ b/app.json @@ -783,10 +783,6 @@ "VERIFIABLE_CREDENTIAL_DID":{ "description": "The Decentralized Identifier (DID) used as the issuer for verifiable credentials.", "required": false - }, - "ENABLE_VERIFIABLE_CREDENTIALS_PROVISIONING":{ - "description": "Whether to enable verifiable credentials provisioning functionality if Posthog feature flag is disabled.", - "required": false } }, "keywords": [ diff --git a/cms/migrations/0057_certificatepage_should_provision_verifiable_credential_and_more.py b/cms/migrations/0057_certificatepage_should_provision_verifiable_credential_and_more.py new file mode 100644 index 0000000000..c838f53efb --- /dev/null +++ b/cms/migrations/0057_certificatepage_should_provision_verifiable_credential_and_more.py @@ -0,0 +1,30 @@ +# Generated by Django 5.1.15 on 2026-03-12 17:01 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("cms", "0056_alter_coursepage_max_price_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="certificatepage", + name="should_provision_verifiable_credential", + field=models.BooleanField( + default=False, + help_text="Whether a verifiable credential should be provisioned for this certificate.", + ), + ), + migrations.AddField( + model_name="certificatepage", + name="verifiable_credential_criteria", + field=models.CharField( + blank=True, + help_text="For verifiable credentials issued for this certificate, this is the criteria narrative field. It should be something descriptive, like a list of completed courses, and may be plaintext or markdown. If it is not supplied, no verifiable credential will be provisioned for those certificates.", + max_length=250, + null=True, + ), + ), + ] diff --git a/cms/models.py b/cms/models.py index 354e8f6316..9576c55c82 100644 --- a/cms/models.py +++ b/cms/models.py @@ -15,7 +15,7 @@ from django.core.exceptions import ValidationError from django.core.serializers.json import DjangoJSONEncoder from django.db import models -from django.forms import ChoiceField, IntegerField +from django.forms import ChoiceField, IntegerField, Textarea from django.http import Http404 from django.template.response import TemplateResponse from django.urls import reverse @@ -373,11 +373,25 @@ class CertificatePage(CourseProgramChildPage): use_json_field=True, ) + verifiable_credential_criteria = models.CharField( # noqa: DJ001 + max_length=250, + null=True, + blank=True, + help_text="For verifiable credentials issued for this certificate, this is the criteria narrative field. It should be something descriptive, like a list of completed courses, and may be plaintext or markdown. If it is not supplied, no verifiable credential will be provisioned for those certificates.", + ) + + should_provision_verifiable_credential = models.BooleanField( + default=False, + help_text="Whether a verifiable credential should be provisioned for this certificate.", + ) + content_panels = [ FieldPanel("product_name"), FieldPanel("CEUs"), FieldPanel("overrides"), FieldPanel("signatories"), + FieldPanel("verifiable_credential_criteria", widget=Textarea), + FieldPanel("should_provision_verifiable_credential"), ] api_fields = [ APIField("product_name"), diff --git a/courses/api.py b/courses/api.py index 3234a57ad4..96c28a417c 100644 --- a/courses/api.py +++ b/courses/api.py @@ -13,7 +13,6 @@ import requests import reversion -from bs4 import BeautifulSoup from django.conf import settings from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError @@ -25,7 +24,6 @@ first_or_none, has_equal_properties, ) -from mitol.olposthog.features import is_enabled from opaque_keys.edx.keys import CourseKey from requests.exceptions import ConnectionError as RequestsConnectionError from requests.exceptions import HTTPError @@ -41,7 +39,6 @@ PROGRAM_TEXT_ID_PREFIX, ) from courses.models import ( - BaseCertificate, BlockedCountry, Course, CourseRun, @@ -93,6 +90,8 @@ from django.db.models.query import QuerySet from edx_api.course_detail.models import CourseMode + from cms.models import CertificatePage + log = logging.getLogger(__name__) UserEnrollments = namedtuple( # noqa: PYI024 @@ -1423,7 +1422,10 @@ def import_courserun_from_edx( # noqa: C901, PLR0913 } -def get_verifiable_credentials_payload(certificate: BaseCertificate) -> dict: +def get_verifiable_credentials_payload( + certificate: CourseRunCertificate | ProgramCertificate, + certificate_page: CertificatePage, +) -> dict: # TODO: We could optimize these queries #noqa: TD002, TD003, FIX002 # It's not a massive priority though, as we have a total of 20k certs in prod as of 12/25 learn_hostname = ENV_TO_LEARN_HOSTNAME_MAP.get( @@ -1435,14 +1437,6 @@ def get_verifiable_credentials_payload(certificate: BaseCertificate) -> dict: course_run = certificate.course_run course = course_run.course course_page = course.page - if not course_page.what_you_learn: - # If it's empty, we can't generate a valid payload as narrative is required. - log.error( - "Error creating verifiable credential - missing 'what_you_learn' for course page %s for certificate %s", - course_page.title, - certificate, - ) - raise InvalidCertificateError course_url_id = course.readable_id url = f"https://{learn_hostname}/courses/{course_url_id}" @@ -1453,10 +1447,7 @@ def get_verifiable_credentials_payload(certificate: BaseCertificate) -> dict: achievement_image_url = ( get_thumbnail_url(course_page) if course_page.feature_image else "" ) - soup = BeautifulSoup(course_page.what_you_learn, "html.parser") - narrative = "\n".join( - [f"- {stripped_string}" for stripped_string in soup.stripped_strings] - ) + narrative = certificate_page.verifiable_credential_criteria elif isinstance(certificate, ProgramCertificate): cert_type = "program" @@ -1470,9 +1461,7 @@ def get_verifiable_credentials_payload(certificate: BaseCertificate) -> dict: achievement_image_url = ( get_thumbnail_url(program_page) if program_page.feature_image else "" ) - narrative = "\n".join( - [f"- {program_course[0].title}" for program_course in program.courses] - ) + narrative = certificate_page.verifiable_credential_criteria else: raise InvalidCertificateError @@ -1558,14 +1547,34 @@ def request_verifiable_credential(payload) -> dict: return resp.json() -def should_provision_verifiable_credential() -> bool: +def should_provision_verifiable_credential( + certificate_page: CertificatePage | None, +) -> bool: + if not certificate_page: + return False + return ( - is_enabled(features.ENABLE_VERIFIABLE_CREDENTIALS_PROVISIONING, False) # noqa: FBT003 - or settings.ENABLE_VERIFIABLE_CREDENTIALS_PROVISIONING + certificate_page.should_provision_verifiable_credential + and certificate_page.verifiable_credential_criteria ) -def create_verifiable_credential(certificate: BaseCertificate, *, raise_on_error=False): +def get_certificate_page( + certificate: CourseRunCertificate | ProgramCertificate, +) -> CertificatePage | None: + from cms.models import CertificatePage # noqa: PLC0415 + + certificate_page = None + if certificate.certificate_page_revision: + certificate_page = CertificatePage.objects.filter( + pk=int(certificate.certificate_page_revision.object_id), + ).first() + return certificate_page + + +def create_verifiable_credential( + certificate: ProgramCertificate | CourseRunCertificate, *, raise_on_error=False +): """ Create a verifiable credential for the given course run certificate. @@ -1573,10 +1582,16 @@ def create_verifiable_credential(certificate: BaseCertificate, *, raise_on_error certificate (CourseRunCertificate): The course run certificate for which to create the verifiable credential. raise_on_error (bool): If True, will re-raise any exceptions encountered during VC creation. """ + try: - if not should_provision_verifiable_credential(): + # We always look at the most recent certificate page revision for content and whether or not to provision + # You can imagine that if we used the linked revision, if the certificate page was in a bad state + # when the certificate was issued, we could never backfill the VC even if we fixed it later. + certificate_page = get_certificate_page(certificate) + + if not should_provision_verifiable_credential(certificate_page): return - payload = get_verifiable_credentials_payload(certificate) + payload = get_verifiable_credentials_payload(certificate, certificate_page) # Call the signing service to create the new credential credential = request_verifiable_credential(payload) diff --git a/courses/api_test.py b/courses/api_test.py index 78d6aa28ad..73a328616b 100644 --- a/courses/api_test.py +++ b/courses/api_test.py @@ -2377,9 +2377,10 @@ def test_course_run_certificate_verifiable_credentials( "courses.api.request_verifiable_credential", side_effect=return_signed_credential, ) - mocker.patch( - "courses.api.should_provision_verifiable_credential", return_value=True - ) + mock_certificate_page = Mock() + mock_certificate_page.verifiable_credential_criteria = "mock_credential_data" + mock_certificate_page.should_provision_verifiable_credential = True + mocker.patch("courses.api.get_certificate_page", return_value=mock_certificate_page) passed_grade_with_enrollment.course_run.course.page.what_you_learn = ( "Some learning content" ) @@ -2412,9 +2413,11 @@ def test_program_certificate_verifiable_credentials( "courses.api.request_verifiable_credential", side_effect=return_signed_credential, ) - mocker.patch( - "courses.api.should_provision_verifiable_credential", return_value=True - ) + + mock_certificate_page = Mock() + mock_certificate_page.verifiable_credential_criteria = "mock_credential_data" + mock_certificate_page.should_provision_verifiable_credential = True + mocker.patch("courses.api.get_certificate_page", return_value=mock_certificate_page) courses = CourseFactory.create_batch(3) course_runs = CourseRunFactory.create_batch(3, course=factory.Iterator(courses)) CourseRunCertificateFactory.create_batch( @@ -2513,7 +2516,9 @@ def test_course_run_certificate_verifiable_credentials_signing_payload( ) course_run_cert.course_run.course.page.save() - payload = get_verifiable_credentials_payload(course_run_cert) + mock_certificate_page = Mock() + mock_certificate_page.verifiable_credential_criteria = "mock_credential_data" + payload = get_verifiable_credentials_payload(course_run_cert, mock_certificate_page) # Assert the expected payload structure expected_payload = { @@ -2552,7 +2557,9 @@ def test_course_run_certificate_verifiable_credentials_signing_payload( "id": "https://learn.mit.edu/courses/course-v1:MITx+6.00.1x", "achievementType": "Course", "type": ["Achievement"], - "criteria": {"narrative": "- Learn Python programming fundamentals"}, + "criteria": { + "narrative": mock_certificate_page.verifiable_credential_criteria + }, "description": "John Doe has successfully completed all modules and earned a Course Certificate in Introduction to Python.", "name": "Introduction to Python", "image": { @@ -2619,12 +2626,9 @@ def test_program_certificate_verifiable_credentials_signing_payload( program_cert.program.add_requirement(course2) program_cert.program.add_requirement(course3) - payload = get_verifiable_credentials_payload(program_cert) - - # Build expected narrative from the actual course titles - narrative = "\n".join( - [f"- {course[0].title}" for course in program_cert.program.courses] - ) + mock_certificate_page = Mock() + mock_certificate_page.verifiable_credential_criteria = "mock_credential_data" + payload = get_verifiable_credentials_payload(program_cert, mock_certificate_page) # Assert the expected payload structure expected_payload = { @@ -2663,7 +2667,9 @@ def test_program_certificate_verifiable_credentials_signing_payload( "id": "https://learn.mit.edu/programs/program-v1:MITx+DataScienceMM", "achievementType": "Program", "type": ["Achievement"], - "criteria": {"narrative": narrative}, + "criteria": { + "narrative": mock_certificate_page.verifiable_credential_criteria + }, "description": "Jane Smith has successfully completed all modules and earned a Program Certificate in Data Science MicroMasters.", "name": "Data Science MicroMasters", "image": { diff --git a/main/features.py b/main/features.py index 63396bfce7..f5be3154a1 100644 --- a/main/features.py +++ b/main/features.py @@ -6,8 +6,5 @@ ENABLE_MULTIPLE_CART_ITEMS = "ENABLE_MULTIPLE_CART_ITEMS" ENABLE_GOOGLE_ANALYTICS_DATA_PUSH = "mitxonline-4099-dedp-google-analytics" -ENABLE_VERIFIABLE_CREDENTIALS_PROVISIONING = ( - "mitxonline-verifiable-credentials-provisioning" -) REDIRECT_LEARN_DASHBOARD = "redirect-to-learn-dashboard" diff --git a/main/settings.py b/main/settings.py index f892ca143b..520d9ff366 100644 --- a/main/settings.py +++ b/main/settings.py @@ -1536,11 +1536,6 @@ default="", description="The Decentralized Identifier (DID) used as the issuer for verifiable credentials.", ) -ENABLE_VERIFIABLE_CREDENTIALS_PROVISIONING = get_bool( - name="ENABLE_VERIFIABLE_CREDENTIALS_PROVISIONING", - default=False, - description="Override posthog flag to enable the provisioning of verifiable credentials in dev.", -) MIT_LEARN_ATTACH_URL = get_string( name="MIT_LEARN_ATTACH_URL", From 352d11bb1c1e78b83a56e9df393e8c06d3aa5ef4 Mon Sep 17 00:00:00 2001 From: cp-at-mit Date: Wed, 18 Mar 2026 11:34:47 -0400 Subject: [PATCH 2/6] Add auth to cart page (#3392) --- config/apisix/apisix.yaml | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/config/apisix/apisix.yaml b/config/apisix/apisix.yaml index 9f466498a0..c4e45c4dbc 100644 --- a/config/apisix/apisix.yaml +++ b/config/apisix/apisix.yaml @@ -63,5 +63,33 @@ routes: - "/login/" - "/admin/login*" + - id: 3 + name: "app-cart" + desc: "Require login for cart so session is established." + priority: 5 + upstream_id: 1 + plugins: + openid-connect: + client_id: ${{KEYCLOAK_CLIENT_ID}} + client_secret: ${{KEYCLOAK_CLIENT_SECRET}} + discovery: ${{KEYCLOAK_DISCOVERY_URL}} + realm: ${{KEYCLOAK_REALM}} + scope: "openid profile ol-profile" + bearer_only: false + introspection_endpoint_auth_method: "client_secret_post" + ssl_verify: false + session: + secret: ${{APISIX_SESSION_SECRET_KEY}} + logout_path: "/logout/oidc" + post_logout_redirect_uri: ${{APP_LOGOUT_URL}} + unauth_action: "auth" + response-rewrite: + headers: + set: + Content-Security-Policy: frame-ancestors 'self' ${{OPENEDX_API_BASE_URL}} + uris: + - "/cart" + - "/cart/" + #END From af667aa28059545b7f4e206ab05651e7cf2a4537 Mon Sep 17 00:00:00 2001 From: Rachel Lougee Date: Wed, 18 Mar 2026 12:42:07 -0400 Subject: [PATCH 3/6] Add edX entitlement import to migrate_edx_data management command (#3383) --- .../management/commands/migrate_edx_data.py | 119 +++++++++++++++++- 1 file changed, 118 insertions(+), 1 deletion(-) diff --git a/courses/management/commands/migrate_edx_data.py b/courses/management/commands/migrate_edx_data.py index c643d62e06..ed8967b51e 100644 --- a/courses/management/commands/migrate_edx_data.py +++ b/courses/management/commands/migrate_edx_data.py @@ -1,6 +1,8 @@ from django.conf import settings from django.core.management.base import BaseCommand +from django.db import transaction from django.db.models import Q +from reversion.models import Version from trino.auth import BasicAuthentication from trino.dbapi import connect @@ -13,7 +15,12 @@ CourseRunEnrollment, CourseRunGrade, Department, + Program, + ProgramEnrollment, ) +from ecommerce.api import fulfill_completed_order +from ecommerce.constants import ZERO_PAYMENT_DATA +from ecommerce.models import PendingOrder, Product from users.models import GENDER_CHOICES, LegalAddress, User, UserProfile @@ -491,6 +498,110 @@ def _migrate_certificates(self, conn, options): ) ) + def _migrate_entitlements(self, conn, options): + """ + Migrate entitlement from edX to MITx Online. Create program Order instances. + """ + limit = options.get("limit") + batch_size = options.get("batch_size", 1000) + + cur = conn.cursor() + + query = ( + "SELECT * FROM edxorg_to_mitxonline_program_entitlements " + "WHERE order_id IS NULL" + ) + + if limit is not None: + query += f" LIMIT {int(limit)}" + + cur.execute(query) + columns = [desc[0] for desc in cur.description] + + created_orders = 0 + + while True: + results = cur.fetchmany(batch_size) + if not results: + break + + batch = [dict(zip(columns, r)) for r in results] + + # Bulk-fetch all objects needed by this batch + program_ids = {row["program_id"] for row in batch} + user_ids = {row["user_mitxonline_id"] for row in batch} + product_version_ids = {row["product_version_id"] for row in batch} + + programs = {p.id: p for p in Program.objects.filter(id__in=program_ids)} + users = {u.id: u for u in User.objects.filter(id__in=user_ids)} + product_versions = { + v.id: v for v in Version.objects.filter(id__in=product_version_ids) + } + + for row in batch: + try: + program = programs.get(row["program_id"]) + user = users.get(row["user_mitxonline_id"]) + product_version = product_versions.get(row["product_version_id"]) + product = ( + Product.all_objects.filter( + id=product_version.field_dict.get("id") + ).first() + if product_version + else None + ) + + if not all([program, user, product_version, product]): + missing = [ + name + for name, val in [ + ("program", program), + ("user", user), + ("product_version", product_version), + ("product", product), + ] + if not val + ] + self.stdout.write( + self.style.ERROR( + f"Missing objects {missing} for row: {row}" + ) + ) + continue + + already_enrolled = ProgramEnrollment.all_objects.filter( + user=user, + program=program, + ).exists() + if already_enrolled: + self.stdout.write( + self.style.WARNING( + f"Skipping order creation: user={user.id} is already enrolled " + f"in program={program.id}" + ) + ) + continue + + with transaction.atomic(): + order = PendingOrder.create_from_product( + product, user, discount=None + ) + fulfill_completed_order( + order, + payment_data=ZERO_PAYMENT_DATA, + already_enrolled=True, + ) + created_orders += 1 + + except Exception as e: # noqa: BLE001 + self.stdout.write( + self.style.ERROR( + f"Failed to migrate entitlement row: {row} error: {e}" + ) + ) + + self.stdout.write(self.style.SUCCESS(f"Created {created_orders} Orders")) + def add_arguments(self, parser) -> None: parser.add_argument( "--use-default-signatory", @@ -510,7 +621,7 @@ def add_arguments(self, parser) -> None: ) parser.add_argument( "--type", - choices=["course_runs", "users", "certificates"], + choices=["course_runs", "users", "certificates", "entitlements"], default="course_runs", help="Choose which migration to run: course_runs, users (default: course_runs)", ) @@ -539,3 +650,9 @@ def handle(self, *args, **options): # pylint: disable=unused-argument # noqa: A "Migrating the edX enrollments, grades and certificates ..." ) self._migrate_certificates(conn, options) + + if migrate_type == "entitlements": + self.stdout.write( + "Migrating the edX entitlements to program orders and enrollments ..." + ) + self._migrate_entitlements(conn, options) From 338c075f9adac6cf9da7fe96ed623a64bb4ea11f Mon Sep 17 00:00:00 2001 From: annagav Date: Wed, 18 Mar 2026 13:05:42 -0400 Subject: [PATCH 4/6] Add program type to the receipt email template (#3395) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- ecommerce/factories.py | 12 ++++- ecommerce/serializers/__init__.py | 14 ++++-- ecommerce/serializers/serializers_test.py | 45 ++++++++++++++++++- .../mail/product_order_receipt/body.html | 7 ++- 4 files changed, 71 insertions(+), 7 deletions(-) diff --git a/ecommerce/factories.py b/ecommerce/factories.py index 6872712d6f..0637834923 100644 --- a/ecommerce/factories.py +++ b/ecommerce/factories.py @@ -4,7 +4,7 @@ from factory import SubFactory, fuzzy from factory.django import DjangoModelFactory -from courses.factories import CourseRunFactory +from courses.factories import CourseRunFactory, ProgramFactory from ecommerce import models from ecommerce.constants import ( ALL_DISCOUNT_TYPES, @@ -30,6 +30,16 @@ class Meta: model = models.Product +class ProgramProductFactory(DjangoModelFactory): + purchasable_object = SubFactory(ProgramFactory) + price = fuzzy.FuzzyDecimal(2, 2000, precision=2) + description = FAKE.sentence(nb_words=4) + is_active = True + + class Meta: + model = models.Product + + 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 diff --git a/ecommerce/serializers/__init__.py b/ecommerce/serializers/__init__.py index c00717e72c..ede1fed06c 100644 --- a/ecommerce/serializers/__init__.py +++ b/ecommerce/serializers/__init__.py @@ -829,13 +829,20 @@ def to_representation(self, instance): content_object = instance.product.purchasable_object (content_title, readable_id) = (None, None) - if isinstance(content_object, ProgramRun): - content_title = content_object.program.title - readable_id = content_object.program.readable_id + if isinstance(content_object, Program): + content_title = content_object.title + readable_id = content_object.readable_id elif isinstance(content_object, CourseRun): readable_id = content_object.course.readable_id content_title = f"{content_object.course_number} {content_object.title}" + # Add content_type from product's content_type model + content_type = ( + instance.product.content_type.model + if hasattr(instance.product, "content_type") + else None + ) + line = dict( # noqa: C408 quantity=instance.quantity, total_paid=str(total_paid), @@ -846,6 +853,7 @@ def to_representation(self, instance): price=str(instance.product.price), start_date=content_object.start_date, end_date=content_object.end_date, + content_type=content_type, ) return line # noqa: RET504 diff --git a/ecommerce/serializers/serializers_test.py b/ecommerce/serializers/serializers_test.py index 68586a70eb..8eed8010f7 100644 --- a/ecommerce/serializers/serializers_test.py +++ b/ecommerce/serializers/serializers_test.py @@ -18,6 +18,7 @@ from ecommerce.factories import ( BasketItemFactory, ProductFactory, + ProgramProductFactory, UnlimitedUseDiscountFactory, ) from ecommerce.models import BasketDiscount, Order, OrderStatus @@ -462,13 +463,13 @@ def test_order_receipt_lines_serializer(settings, mocker, user, products, user_c elif isinstance(content_object, CourseRun): readable_id = content_object.course.readable_id content_title = f"{content_object.course_number} {content_object.title}" - line = dict( # noqa: C408 quantity=instance.quantity, total_paid=str(total_paid), discount=str(discount), CEUs=None, content_title=content_title, + content_type=instance.product.content_type.model, readable_id=readable_id, price=str(instance.product.price), start_date=content_object.start_date, @@ -479,3 +480,45 @@ def test_order_receipt_lines_serializer(settings, mocker, user, products, user_c serialized_data = TransactionLineSerializer(instance=order.lines, many=True).data assert serialized_data == test_data["lines"] + + +@pytest.mark.skip_nplusone_check +def test_program_order_receipt_lines_serializer(settings, mocker, user, user_client): + settings.OPENEDX_SERVICE_WORKER_API_TOKEN = "mock_api_token" # noqa: S105 + + with reversion.create_revision(): + products = ProgramProductFactory.create_batch(5) + (order, test_data) = get_receipt_serializer_test_data( + mocker, user, products, user_client + ) + + for instance in order.lines.all(): + coupon_redemption = instance.order.discounts.first() + discount = 0.0 + + if coupon_redemption: + discount = instance.product.price - instance.discounted_price + + total_paid = (instance.product.price - Decimal(discount)) * instance.quantity + + content_object = instance.product.purchasable_object + + content_title = content_object.title + readable_id = content_object.readable_id + + line = dict( # noqa: C408 + quantity=instance.quantity, + total_paid=str(total_paid), + discount=str(discount), + CEUs=None, + content_title=content_title, + content_type=instance.product.content_type.model, + readable_id=readable_id, + price=str(instance.product.price), + start_date=content_object.start_date, + end_date=content_object.end_date, + ) + test_data["lines"].append(line) + + serialized_data = TransactionLineSerializer(instance=order.lines, many=True).data + assert serialized_data == test_data["lines"] diff --git a/ecommerce/templates/mail/product_order_receipt/body.html b/ecommerce/templates/mail/product_order_receipt/body.html index d99f991a38..b28523540b 100755 --- a/ecommerce/templates/mail/product_order_receipt/body.html +++ b/ecommerce/templates/mail/product_order_receipt/body.html @@ -5,8 +5,11 @@ From 6118bce53c938fab4cab25b6bc9db4e5fc45c806 Mon Sep 17 00:00:00 2001 From: Chris Chudzicki Date: Wed, 18 Mar 2026 14:13:39 -0400 Subject: [PATCH 5/6] Restore product type information in OpenAPI Spec (#3396) --- courses/serializers/v1/base.py | 3 ++- courses/serializers/v1/courses_test.py | 2 +- courses/serializers/v2/courses.py | 2 +- openapi/specs/v0.yaml | 6 ++++-- openapi/specs/v1.yaml | 6 ++++-- openapi/specs/v2.yaml | 6 ++++-- 6 files changed, 16 insertions(+), 9 deletions(-) diff --git a/courses/serializers/v1/base.py b/courses/serializers/v1/base.py index a8742329dc..17383f25cc 100644 --- a/courses/serializers/v1/base.py +++ b/courses/serializers/v1/base.py @@ -7,7 +7,8 @@ from courses import models from courses.constants import CONTENT_TYPE_MODEL_COURSE, CONTENT_TYPE_MODEL_PROGRAM from courses.utils import get_approved_flexible_price_exists -from ecommerce.serializers import BaseProductSerializer, ProductFlexibilePriceSerializer +from ecommerce.serializers import ProductFlexibilePriceSerializer +from ecommerce.serializers.v0 import BaseProductSerializer from openedx.constants import EDX_ENROLLMENT_AUDIT_MODE, EDX_ENROLLMENT_VERIFIED_MODE diff --git a/courses/serializers/v1/courses_test.py b/courses/serializers/v1/courses_test.py index ee75a0d486..596ec76dd3 100644 --- a/courses/serializers/v1/courses_test.py +++ b/courses/serializers/v1/courses_test.py @@ -22,7 +22,7 @@ CourseWithCourseRunsSerializer, ) from courses.serializers.v1.programs import ProgramSerializer -from ecommerce.serializers import BaseProductSerializer +from ecommerce.serializers.v0 import BaseProductSerializer from flexiblepricing.constants import FlexiblePriceStatus from flexiblepricing.factories import FlexiblePriceFactory from main.test_utils import assert_drf_json_equal, drf_datetime diff --git a/courses/serializers/v2/courses.py b/courses/serializers/v2/courses.py index 4a56ad370c..c0db2b0eef 100644 --- a/courses/serializers/v2/courses.py +++ b/courses/serializers/v2/courses.py @@ -257,7 +257,7 @@ def to_representation(self, instance): def get_approved_flexible_price_exists(self, instance): return get_approved_flexible_price_exists(instance, self.context) - @extend_schema_field(list) + @extend_schema_field(BaseProductSerializer(many=True)) def get_products(self, obj): # Use prefetched products if available to avoid N+1 queries products = ( diff --git a/openapi/specs/v0.yaml b/openapi/specs/v0.yaml index d98da396b0..a62f47c18a 100644 --- a/openapi/specs/v0.yaml +++ b/openapi/specs/v0.yaml @@ -4308,7 +4308,8 @@ components: readOnly: true products: type: array - items: {} + items: + $ref: '#/components/schemas/BaseProduct' readOnly: true approved_flexible_price_exists: type: boolean @@ -8032,7 +8033,8 @@ components: readOnly: true products: type: array - items: {} + items: + $ref: '#/components/schemas/BaseProduct' readOnly: true approved_flexible_price_exists: type: boolean diff --git a/openapi/specs/v1.yaml b/openapi/specs/v1.yaml index a6346fb72e..68796e1a9c 100644 --- a/openapi/specs/v1.yaml +++ b/openapi/specs/v1.yaml @@ -4308,7 +4308,8 @@ components: readOnly: true products: type: array - items: {} + items: + $ref: '#/components/schemas/BaseProduct' readOnly: true approved_flexible_price_exists: type: boolean @@ -8032,7 +8033,8 @@ components: readOnly: true products: type: array - items: {} + items: + $ref: '#/components/schemas/BaseProduct' readOnly: true approved_flexible_price_exists: type: boolean diff --git a/openapi/specs/v2.yaml b/openapi/specs/v2.yaml index 6fbe89eab5..955f9cc5d0 100644 --- a/openapi/specs/v2.yaml +++ b/openapi/specs/v2.yaml @@ -4308,7 +4308,8 @@ components: readOnly: true products: type: array - items: {} + items: + $ref: '#/components/schemas/BaseProduct' readOnly: true approved_flexible_price_exists: type: boolean @@ -8032,7 +8033,8 @@ components: readOnly: true products: type: array - items: {} + items: + $ref: '#/components/schemas/BaseProduct' readOnly: true approved_flexible_price_exists: type: boolean From 800cc897cc3377f04d828adf32889a5774927cf2 Mon Sep 17 00:00:00 2001 From: Doof Date: Wed, 18 Mar 2026 20:28:38 +0000 Subject: [PATCH 6/6] Release 1.142.2 --- RELEASE.rst | 9 +++++++++ main/settings.py | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/RELEASE.rst b/RELEASE.rst index f01d6ea0c4..1898084df2 100644 --- a/RELEASE.rst +++ b/RELEASE.rst @@ -1,6 +1,15 @@ Release Notes ============= +Version 1.142.2 +--------------- + +- Restore product type information in OpenAPI Spec (#3396) +- Add program type to the receipt email template (#3395) +- Add edX entitlement import to migrate_edx_data management command (#3383) +- Add auth to cart page (#3392) +- Add criteria field and course-level feature flag to CMS CertificatePage (#3373) + Version 1.142.1 (Released March 17, 2026) --------------- diff --git a/main/settings.py b/main/settings.py index 57565559bb..415b7bf654 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.142.1" +VERSION = "1.142.2" log = logging.getLogger()

Dear {{ purchaser.name }},

-

You have been enrolled {% if content_title %} in {{ content_title }}{% endif %}. - The course should now appear on your MITxOnline dashboard. You can also access your receipt by clicking here. +

+ You have been enrolled {% if content_title %} in {{ content_title }}{% endif %} + The {% if lines and lines.0.content_type %} + {% if lines.0.content_type == "program" %}program{% else %}course{% endif %} + {% endif %} should now appear on your MITxOnline dashboard. You can also access your receipt by clicking here.

Below you will find a copy of your receipt: