From 4f91f3f70be8b4f05c95b73cfeaff5ed59b16052 Mon Sep 17 00:00:00 2001 From: James Kachel Date: Thu, 9 Apr 2026 08:13:18 -0500 Subject: [PATCH 1/3] Add some options to the create_courseware admin command (#3467) --- .../management/commands/create_courseware.py | 139 ++++++++++++++++-- 1 file changed, 124 insertions(+), 15 deletions(-) diff --git a/courses/management/commands/create_courseware.py b/courses/management/commands/create_courseware.py index c2d0c7dbbe..6679b77203 100644 --- a/courses/management/commands/create_courseware.py +++ b/courses/management/commands/create_courseware.py @@ -4,10 +4,12 @@ """ from datetime import timedelta +from decimal import Decimal from typing import List, Union # noqa: UP035 +import reversion from django.core.management import BaseCommand -from django.db import models +from django.db import models, transaction from cms.api import ( create_default_certificate_page, @@ -15,8 +17,10 @@ create_default_signatory_page, get_optional_placeholder_values_for_courseware_type, ) -from courses.models import Course, CourseRun, Department, Program +from courses.models import Course, CourseRun, Department, EnrollmentMode, Program +from ecommerce.models import Product from main.utils import now_datetime_with_tz, parse_supplied_date +from openedx.constants import EDX_ENROLLMENT_AUDIT_MODE, EDX_ENROLLMENT_VERIFIED_MODE class Command(BaseCommand): @@ -286,10 +290,72 @@ def _create_course_run(self, course, **kwargs): is_source_run=kwargs.get("create_run_as_sourcerun", False), ) + if kwargs["price"]: + self._create_product(course_run, kwargs["price"]) + + if kwargs["mode_audit"]: + self._add_enrollment_mode(course_run, EDX_ENROLLMENT_AUDIT_MODE) + + if kwargs["mode_verified"]: + self._add_enrollment_mode(course_run, EDX_ENROLLMENT_VERIFIED_MODE) + self.stdout.write( self.style.SUCCESS(f"Created course run {course_run.id}: {course_run}") ) + def _create_product(self, courserun_or_program, price): + """Create a product for the specified course run or program.""" + + if not isinstance(courserun_or_program, CourseRun) and not isinstance( + courserun_or_program, Program + ): + self.stdout.write( + self.style.WARNING( + f"Price specified for {courserun_or_program} but it's not a course run or program, so skipping product creation." + ) + ) + return + + with reversion.create_revision(): + new_product = Product.objects.create( + purchasable_object=courserun_or_program, + price=price, + description=str(courserun_or_program), + is_active=True, + ) + + self.stdout.write( + self.style.SUCCESS( + f"Created product {new_product} for {courserun_or_program}" + ) + ) + + def _add_enrollment_mode(self, courserun_or_program, mode_slug): + """Add the specified mode to the course run or program.""" + + if not isinstance(courserun_or_program, CourseRun) and not isinstance( + courserun_or_program, Program + ): + self.stdout.write( + self.style.WARNING( + f"Enrollment mode {mode_slug} specified for {courserun_or_program} but it's not a course run or program, so skipping modes." + ) + ) + return + + mode = EnrollmentMode.objects.filter(mode_slug=mode_slug).first() + + if not mode: + self.stdout.write( + self.style.ERROR( + f"Enrollment mode {mode_slug} not found - this probably means things aren't configured correctly." + ) + ) + return + + courserun_or_program.enrollment_modes.add(mode) + courserun_or_program.save() + def _handle_program(self, add_depts: models.QuerySet, **kwargs): """Handle creation of a program.""" self._check_if_courseware_object_readable_id_exists( @@ -300,6 +366,8 @@ def _handle_program(self, add_depts: models.QuerySet, **kwargs): readable_id=kwargs["courseware_id"], title=kwargs["title"], live=kwargs["live"], + b2b_only=kwargs.pop("b2b_only", False), + display_mode=kwargs.pop("program_course", None), ) if add_depts: @@ -337,6 +405,15 @@ def _handle_program(self, add_depts: models.QuerySet, **kwargs): create_signatory=kwargs["create_signatory"], ) + if kwargs["price"]: + self._create_product(new_program, kwargs["price"]) + + if kwargs["mode_audit"]: + self._add_enrollment_mode(new_program, EDX_ENROLLMENT_AUDIT_MODE) + + if kwargs["mode_verified"]: + self._add_enrollment_mode(new_program, EDX_ENROLLMENT_VERIFIED_MODE) + def _handle_course(self, add_depts: models.QuerySet, **kwargs): """Handle creation of a course.""" self._check_if_courseware_object_readable_id_exists( @@ -564,6 +641,35 @@ def add_arguments(self, parser) -> None: action="store_true", ) + parser.add_argument( + "--price", + help="If set, create a product for the item. (Must be a course run or a program.)", + type=Decimal, + ) + + parser.add_argument( + "--mode-audit", + help="Add the audit mode to the courseware. (Must be a course run or program.)", + action="store_true", + ) + parser.add_argument( + "--mode-verified", + help="Add the verified mode to the courseware. (Must be a course run or program.)", + action="store_true", + ) + + parser.add_argument( + "--b2b-only", + help="Make the program B2B-only.", + action="store_true", + ) + parser.add_argument( + "--program-course", + help="Make the program a program-course (displays like a course).", + action="store_const", + const="course", + ) + def handle(self, *_args, **kwargs): if not ( kwargs["force"] @@ -578,17 +684,20 @@ def handle(self, *_args, **kwargs): exit(-1) # noqa: PLR1722 # Create or get departments if specified. - add_depts = None - if kwargs.get("depts"): - add_depts = self._get_or_create_departments( - kwargs["depts"], create_if_missing=kwargs["create_depts"] - ) + with transaction.atomic(): + add_depts = None + if kwargs.get("depts"): + add_depts = self._get_or_create_departments( + kwargs["depts"], create_if_missing=kwargs["create_depts"] + ) - if kwargs["type"] == "program": - self._handle_program(add_depts, **kwargs) - elif kwargs["type"] == "course": - self._handle_course(add_depts, **kwargs) - elif kwargs["type"] == "courserun": - self._handle_courserun(**kwargs) - else: - self.stderr.write(self.style.ERROR(f"Not sure what {kwargs['type']} is.")) + if kwargs["type"] == "program": + self._handle_program(add_depts, **kwargs) + elif kwargs["type"] == "course": + self._handle_course(add_depts, **kwargs) + elif kwargs["type"] == "courserun": + self._handle_courserun(**kwargs) + else: + self.stderr.write( + self.style.ERROR(f"Not sure what {kwargs['type']} is.") + ) From db6ca9497b6f916155a4806334384cb3debad2f9 Mon Sep 17 00:00:00 2001 From: Chris Chudzicki Date: Thu, 9 Apr 2026 14:05:56 -0400 Subject: [PATCH 2/3] Use correct run-level prefix when redirecting UAI industry-specific verticals (#3479) --- ecommerce/views/legacy/__init__.py | 2 +- ecommerce/views/legacy/views_test.py | 42 ++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/ecommerce/views/legacy/__init__.py b/ecommerce/views/legacy/__init__.py index 835713d73a..6e19388fd1 100644 --- a/ecommerce/views/legacy/__init__.py +++ b/ecommerce/views/legacy/__init__.py @@ -94,7 +94,7 @@ UAI_READABLE_ID_PREFIXES = [ "program-v1:UAI+B2C", - "course-v1:UAI_SOURCE+UAI.", + "course-v1:UAI_B2C", ] diff --git a/ecommerce/views/legacy/views_test.py b/ecommerce/views/legacy/views_test.py index 209a62e599..23acb469e4 100644 --- a/ecommerce/views/legacy/views_test.py +++ b/ecommerce/views/legacy/views_test.py @@ -621,6 +621,48 @@ def test_checkout_result( # noqa: PLR0913 assert Basket.objects.filter(id=basket.id).exists() is basket_exists +@pytest.mark.skip_nplusone_check +@pytest.mark.dont_mock_enrollments +def test_checkout_result_redirects_uai_b2c_courserun_to_learn_dashboard( + settings, + user, + user_client, + mocker, +): + """Accepted UAI+B2C course run purchases should redirect to the MIT Learn dashboard.""" + settings.MIT_LEARN_DASHBOARD_URL = "https://learn.mit.edu/dashboard" + + mocker.patch("hubspot_sync.tasks.sync_deal_with_hubspot.apply_async") + mocker.patch( + "mitol.payment_gateway.api.PaymentGateway.validate_processor_response", + return_value=True, + ) + + run = CourseRunFactory.create(courseware_id="course-v1:UAI_B2C+TEST+2026") + with reversion.create_revision(): + product = ProductFactory.create(purchasable_object=run) + + create_basket_with_product(user, product) + + checkout_payload = create_pending_order(user) + + payload = checkout_payload["payload"] + payload = { + **{f"req_{key}": value for key, value in payload.items()}, + "decision": "ACCEPT", + "message": "payment processor message", + "transaction_id": "12345", + } + + resp = user_client.post(reverse("checkout-result-callback"), payload) + assert resp.status_code == 302 + order = Order.objects.get(purchaser=user, state=OrderStatus.FULFILLED) + assert ( + resp.url + == f"{settings.MIT_LEARN_DASHBOARD_URL}?order_status=fulfilled&order_id={order.id}" + ) + + @pytest.mark.skip_nplusone_check @pytest.mark.dont_mock_enrollments def test_checkout_result_redirects_uai_b2c_program_to_learn_dashboard( From 934063c66959c49cfa1e7c25a03a4c1972a8d8a8 Mon Sep 17 00:00:00 2001 From: Doof Date: Thu, 9 Apr 2026 18:08:48 +0000 Subject: [PATCH 3/3] Release 1.146.1 --- RELEASE.rst | 6 ++++++ main/settings.py | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/RELEASE.rst b/RELEASE.rst index 01998389d1..c5cdb12b00 100644 --- a/RELEASE.rst +++ b/RELEASE.rst @@ -1,6 +1,12 @@ Release Notes ============= +Version 1.146.1 +--------------- + +- Use correct run-level prefix when redirecting UAI industry-specific verticals (#3479) +- Add some options to the create_courseware admin command (#3467) + Version 1.146.0 (Released April 09, 2026) --------------- diff --git a/main/settings.py b/main/settings.py index d6293854e5..6e51df9c8f 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.146.0" +VERSION = "1.146.1" log = logging.getLogger()