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

Expand Down
139 changes: 124 additions & 15 deletions courses/management/commands/create_courseware.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,23 @@
"""

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,
create_default_courseware_page,
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):
Expand Down Expand Up @@ -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(
Expand All @@ -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:
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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"]
Expand All @@ -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.")
)
2 changes: 1 addition & 1 deletion ecommerce/views/legacy/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@

UAI_READABLE_ID_PREFIXES = [
"program-v1:UAI+B2C",
"course-v1:UAI_SOURCE+UAI.",
"course-v1:UAI_B2C",
]


Expand Down
42 changes: 42 additions & 0 deletions ecommerce/views/legacy/views_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
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.146.0"
VERSION = "1.146.1"

log = logging.getLogger()

Expand Down
Loading