diff --git a/RELEASE.rst b/RELEASE.rst
index 930e7253cc..1f96600932 100644
--- a/RELEASE.rst
+++ b/RELEASE.rst
@@ -1,6 +1,19 @@
Release Notes
=============
+Version 1.144.0
+---------------
+
+- fix: typo fixed in enrollment_failure template (#3433)
+- fix: remove locally taken usernames from suggestions (#3416)
+- Remove automatic program enrollment (#3425)
+- Stop checking elective enrollments when creating a verified course enrollment for a program (#3430)
+- feat: migrate to mitol-django-observability plugin (#3346)
+- Redirect UAI+B2C purchases to Learn dashboard (#3429)
+- Add "include in learn catalog" setting for programs to mitxonline cms (#3412)
+- Ensure Product admin creates reversion entries (#3423)
+- chore(deps): update dependency requests to v2.33.0 [security] (#3427)
+
Version 1.143.4 (Released March 26, 2026)
---------------
diff --git a/cms/api.py b/cms/api.py
index f860f5b238..c788aef2f9 100644
--- a/cms/api.py
+++ b/cms/api.py
@@ -440,7 +440,7 @@ def create_default_courseware_page(
"include_in_learn_catalog": include_in_learn_catalog,
"ingest_content_files_for_ai": ingest_content_files_for_ai,
}
- program_only_kwargs = {}
+ program_only_kwargs = {"include_in_learn_catalog": include_in_learn_catalog}
if optional_kwargs is None:
optional_kwargs = {}
diff --git a/cms/migrations/0058_programpage_include_in_learn_catalog.py b/cms/migrations/0058_programpage_include_in_learn_catalog.py
new file mode 100644
index 0000000000..11c3e94f97
--- /dev/null
+++ b/cms/migrations/0058_programpage_include_in_learn_catalog.py
@@ -0,0 +1,30 @@
+# Generated by Django 5.1.9 on 2026-03-20 00:00
+
+from django.db import migrations, models
+
+
+def set_include_in_learn_catalog_for_live_program_pages(apps, schema_editor):
+ ProgramPage = apps.get_model("cms", "ProgramPage")
+ ProgramPage.objects.filter(live=True).update(include_in_learn_catalog=True)
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("cms", "0057_certificatepage_should_provision_verifiable_credential_and_more"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="programpage",
+ name="include_in_learn_catalog",
+ field=models.BooleanField(
+ default=False,
+ help_text="If true, Learn should include this in its catalog.",
+ null=True,
+ ),
+ ),
+ migrations.RunPython(
+ set_include_in_learn_catalog_for_live_program_pages,
+ migrations.RunPython.noop,
+ ),
+ ]
diff --git a/cms/models.py b/cms/models.py
index 9576c55c82..eadffc479c 100644
--- a/cms/models.py
+++ b/cms/models.py
@@ -1470,6 +1470,11 @@ class ProgramPage(ProductPage):
blank=True,
null=True,
)
+ include_in_learn_catalog = models.BooleanField(
+ default=False,
+ null=True,
+ help_text="If true, Learn should include this in its catalog.",
+ )
template = "product_page.html"
search_fields = Page.search_fields + [ # noqa: RUF005
@@ -1480,12 +1485,17 @@ class ProgramPage(ProductPage):
],
)
]
- content_panels = [ # noqa: RUF005
- FieldPanel("program"),
- ] + ProductPage.content_panels
+ content_panels = (
+ [ # noqa: RUF005
+ FieldPanel("program"),
+ ]
+ + ProductPage.content_panels
+ + [FieldPanel("include_in_learn_catalog")]
+ )
api_fields = [
*ProductPage.api_fields,
APIField("program_details"),
+ APIField("include_in_learn_catalog"),
]
@property
diff --git a/cms/serializers.py b/cms/serializers.py
index 6b4ca8331c..124891946e 100644
--- a/cms/serializers.py
+++ b/cms/serializers.py
@@ -372,6 +372,7 @@ class Meta:
"financial_assistance_form_url",
"description",
"live",
+ "include_in_learn_catalog",
"length",
"effort",
"price",
diff --git a/cms/serializers_test.py b/cms/serializers_test.py
index 4974244cc9..270c32cff1 100644
--- a/cms/serializers_test.py
+++ b/cms/serializers_test.py
@@ -411,6 +411,7 @@ def test_serialize_program_page(
"length": program_page.length,
"effort": program_page.effort,
"price": None,
+ "include_in_learn_catalog": False,
},
)
@@ -461,6 +462,7 @@ def test_serialize_program_page__form_child_of_course_with_program_fk(
"length": program_page.length,
"effort": program_page.effort,
"price": None,
+ "include_in_learn_catalog": False,
},
)
@@ -499,6 +501,7 @@ def test_serialize_program_page__with_related_financial_form(
"length": program_page.length,
"effort": program_page.effort,
"price": None,
+ "include_in_learn_catalog": False,
},
)
@@ -531,6 +534,7 @@ def test_serialize_program_page__no_financial_form(
"length": program_page.length,
"effort": program_page.effort,
"price": None,
+ "include_in_learn_catalog": False,
},
)
@@ -566,6 +570,7 @@ def test_serialize_program_page__with_related_program_no_financial_form(
"length": program_page.length,
"effort": program_page.effort,
"price": None,
+ "include_in_learn_catalog": False,
},
)
diff --git a/cms/wagtail_api/views.py b/cms/wagtail_api/views.py
index 450aa078dc..1bb1527abd 100644
--- a/cms/wagtail_api/views.py
+++ b/cms/wagtail_api/views.py
@@ -80,7 +80,10 @@ def get_queryset(self):
if model_type and not self.request.user.is_authenticated:
if model_type == PageType.PROGRAM.value:
- queryset = queryset.filter(program__b2b_only=False)
+ queryset = queryset.filter(
+ program__b2b_only=False,
+ include_in_learn_catalog=True,
+ )
elif model_type == PageType.COURSE.value:
queryset = queryset.filter(include_in_learn_catalog=True)
diff --git a/courses/api.py b/courses/api.py
index 15c0f5fd9c..0a98c309bc 100644
--- a/courses/api.py
+++ b/courses/api.py
@@ -175,25 +175,6 @@ def create_run_enrollments( # noqa: C901
def send_enrollment_emails():
subscribe_edx_course_emails.delay(enrollment.id)
- def _enroll_learner_into_associated_programs():
- """
- Enrolls the learner into all programs for which the course they are enrolling into
- is associated as a requirement or elective. If a program enrollment already exists
- then the change_status of that program_enrollment is checked to ensure it equals None.
- """
- for program in run.course.programs:
- if not program.live:
- continue
- program_enrollment, _ = ProgramEnrollment.objects.get_or_create(
- user=user,
- program=program,
- defaults=dict( # noqa: C408
- change_status=None,
- ),
- )
- if program_enrollment.change_status is not None:
- program_enrollment.reactivate_and_save()
-
edx_request_success = True
if not runs[0].is_fake_course_run:
# Make the API call to enroll the user in edX only if the run is not a fake course run
@@ -233,8 +214,6 @@ def _enroll_learner_into_associated_programs():
),
)
- _enroll_learner_into_associated_programs()
-
# If the run is associated with a B2B contract, add the contract
# to the user's contract list and update their org memberships
if run.b2b_contract:
diff --git a/courses/api_test.py b/courses/api_test.py
index 73a328616b..54261406eb 100644
--- a/courses/api_test.py
+++ b/courses/api_test.py
@@ -236,8 +236,6 @@ def test_create_run_enrollments_upgrade(
"""
create_run_enrollments should call the edX API to create/update enrollments, and set the enrollment mode properly
in the event of an upgrade e.g a user moving from Audit to Verified mode
-
- In addition, tests to make sure there's a ProgramEnrollment for the course.
"""
test_enrollment = CourseRunEnrollmentFactory.create(
user=user,
@@ -265,7 +263,7 @@ def test_create_run_enrollments_upgrade(
assert edx_request_success is True
test_enrollment.refresh_from_db()
assert test_enrollment.enrollment_mode == EDX_ENROLLMENT_VERIFIED_MODE
- assert ProgramEnrollment.objects.filter(
+ assert not ProgramEnrollment.objects.filter(
user=user, program=program_with_empty_requirements
).exists()
@@ -278,7 +276,7 @@ def test_create_run_enrollments_multiple_programs(
"""
create_run_enrollments should enroll the user into any Programs which have the CourseRun's Course defined as a requirement or elective.
- In addition, tests to make sure there's a ProgramEnrollment for the course.
+ In addition, tests to make sure a ProgramEnrollment is created for the course.
"""
test_enrollment = CourseRunEnrollmentFactory.create(
user=user,
@@ -312,10 +310,10 @@ def test_create_run_enrollments_multiple_programs(
user, runs=[test_enrollment.run], mode=EDX_ENROLLMENT_VERIFIED_MODE
)
- assert ProgramEnrollment.objects.filter(
+ assert not ProgramEnrollment.objects.filter(
user=user, program=program_with_empty_requirements
).exists()
- assert ProgramEnrollment.objects.filter(user=user, program=program2).exists()
+ assert not ProgramEnrollment.objects.filter(user=user, program=program2).exists()
@pytest.mark.parametrize(
@@ -1610,35 +1608,6 @@ def test_generate_program_certificate_failure_not_all_passed_nested_elective_sti
assert len(ProgramCertificate.objects.all()) == 0
-def test_program_enrollment_unenrollment_re_enrollment(
- mocker,
- user,
- program_with_empty_requirements, # noqa: F811
-):
- """
- create_run_enrollments should always enroll a learner into a program even
- if the learner has previously unenrolled from the program.
- """
-
- # Create a program_enrollment that mocks what exists after a learner unenrolls from
- # a program.
- ProgramEnrollmentFactory(
- user=user,
- program=program_with_empty_requirements,
- change_status=ENROLL_CHANGE_STATUS_UNENROLLED,
- )
- course_run = CourseRunFactory.create()
- program_with_empty_requirements.add_requirement(course_run.course)
- mocker.patch("courses.api.enroll_in_edx_course_runs")
- mocker.patch("courses.api.mail_api.send_course_run_enrollment_email")
- mocker.patch("courses.tasks.subscribe_edx_course_emails.delay")
-
- create_run_enrollments(user, runs=[course_run], mode=EDX_ENROLLMENT_VERIFIED_MODE)
- assert ProgramEnrollment.objects.filter(
- user=user, program=program_with_empty_requirements, change_status=None
- ).exists()
-
-
@patch("courses.signals.upsert_custom_properties")
def test_generate_program_certificate_with_subprogram_requirement(
mock_upsert_custom_properties, user, mocker
diff --git a/courses/templates/mail/enrollment_failure/body.html b/courses/templates/mail/enrollment_failure/body.html
index 37d7f27728..958955fc88 100644
--- a/courses/templates/mail/enrollment_failure/body.html
+++ b/courses/templates/mail/enrollment_failure/body.html
@@ -1,4 +1,4 @@
-{% extend "mail/support" %}
+{% extends "mail/support" %}
{{enrollment_type}} #{{enrollment_obj.id}} ({{enrollment_obj.title}})
diff --git a/courses/views/v2/__init__.py b/courses/views/v2/__init__.py
index af179dfa58..4144f46c80 100644
--- a/courses/views/v2/__init__.py
+++ b/courses/views/v2/__init__.py
@@ -680,33 +680,14 @@ def _create_course_enrollment_from_program(request, courserun_id, program_enroll
# Check if:
# .. the program enrollment is audit,
- # .. if the run isn't in the program,
- # .. if the run is an elective, and if the learner already has enough verified elective enrollments
- # and create an audit enrollment if any of these are true.
+ # .. if the run isn't in the program
- if (
- (program_enrollment.enrollment_mode == EDX_ENROLLMENT_AUDIT_MODE)
- or (
- run.course
- not in [
- *program_enrollment.program.required_courses,
- *program_enrollment.program.elective_courses,
- ]
- )
- or (
- run.course not in program_enrollment.program.required_courses
- and (
- CourseRunEnrollment.objects.filter(
- run__course__in=program_enrollment.program.elective_courses,
- user=request.user,
- active=True,
- enrollment_mode=EDX_ENROLLMENT_VERIFIED_MODE,
- ).count()
- >= (
- program_enrollment.program.minimum_elective_courses_requirement or 1
- )
- )
- )
+ if (program_enrollment.enrollment_mode == EDX_ENROLLMENT_AUDIT_MODE) or (
+ run.course
+ not in [
+ *program_enrollment.program.required_courses,
+ *program_enrollment.program.elective_courses,
+ ]
):
# Audit enrollments just get created, regardless of whether or not
# the course is an elective.
@@ -760,11 +741,7 @@ def add_verified_program_course_enrollment(request, courserun_id: str):
Some special handling is needed for program-related course run enrollments
when the learner has an enrollment in the program. The learner should get a
course run enrollment that matches their program enrollment at no additional
- charge. However, if the learner is enrolling in a course that's an elective,
- and they have already enrolled in enough electives to satisfy the program's
- requirements, they should then get an audit enrollment. (This won't preclude
- them from getting a certificate for the course itself but they'll have to buy
- the upgrade separately.)
+ charge.
"""
program_ids = request.data
@@ -936,7 +913,7 @@ def get_program_certificate(request, cert_uuid):
class UserProgramEnrollmentsViewSet(viewsets.ViewSet):
- """ViewSet for user program enrollments with v2 serializers."""
+ """ViewSet for user program and courserun enrollments with v2 serializers."""
permission_classes = [IsAuthenticated]
diff --git a/courses/views/v2/views_test.py b/courses/views/v2/views_test.py
index a6db3db419..7f3ca5d2af 100644
--- a/courses/views/v2/views_test.py
+++ b/courses/views/v2/views_test.py
@@ -1739,7 +1739,7 @@ def test_add_verified_program_course_enrollment(
if requirement_type == "elective-extra":
# Add another elective, adjust the requirement to only require one, and
# give the user a verified enrollment in that course. This should result
- # in the learner getting an _audit_ enrollment in the course created
+ # in the learner getting a verified enrollment in the course created
# earlier.
second_elective = CourseRunFactory.create()
program.add_elective(second_elective.course)
@@ -1766,22 +1766,12 @@ def test_add_verified_program_course_enrollment(
assert resp.status_code == status.HTTP_201_CREATED
assert resp.json()["run"]["id"] == course_run.id
- if (
- program_enrollment_type == EDX_ENROLLMENT_VERIFIED_MODE
- and requirement_type != "elective-extra"
- ):
+ if program_enrollment_type == EDX_ENROLLMENT_VERIFIED_MODE:
order = user.orders.last()
line = order.lines.last()
assert course_run == line.purchased_object
assert resp.json()["enrollment_mode"] == EDX_ENROLLMENT_VERIFIED_MODE
- if (
- program_enrollment_type == EDX_ENROLLMENT_VERIFIED_MODE
- and requirement_type == "elective-extra"
- ):
- # We had enough electives so we should have gotten an audit enrollment
- assert resp.json()["enrollment_mode"] == EDX_ENROLLMENT_AUDIT_MODE
-
@pytest.mark.skip_nplusone_check
@responses.activate
diff --git a/ecommerce/admin.py b/ecommerce/admin.py
index f2d577535d..d0e82f9a69 100644
--- a/ecommerce/admin.py
+++ b/ecommerce/admin.py
@@ -1,5 +1,6 @@
"""Admin management for Ecommerce module"""
+import reversion
from django.contrib import admin, messages
from django.contrib.admin.decorators import display
from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin
@@ -79,6 +80,12 @@ def get_queryset(self, request): # noqa: ARG002
"""
return self.model.all_objects.get_queryset()
+ def save_model(self, request, obj, form, change):
+ """Ensure Product saves via admin always produce a reversion entry."""
+ with reversion.create_revision():
+ super().save_model(request, obj, form, change)
+ reversion.set_user(request.user)
+
@admin.register(Basket)
class BasketAdmin(VersionAdmin):
diff --git a/ecommerce/admin_test.py b/ecommerce/admin_test.py
index 28f0c9ebb9..4945834ce5 100644
--- a/ecommerce/admin_test.py
+++ b/ecommerce/admin_test.py
@@ -1,11 +1,14 @@
"""Tests for ecommerce admin views"""
import pytest
+from django.contrib.contenttypes.models import ContentType
from django.contrib.messages import get_messages
from django.urls import reverse
+from reversion.models import Version
+from courses.factories import CourseRunFactory
from ecommerce.factories import OrderFactory
-from ecommerce.models import OrderStatus
+from ecommerce.models import OrderStatus, Product
pytestmark = [pytest.mark.django_db]
@@ -194,3 +197,28 @@ def test_admin_refund_order_post_order_not_found(client, admin_user):
str(messages[0].message)
== f"Order {missing_order_id} could not be found - is it Fulfilled?"
)
+
+
+def test_admin_product_create_generates_reversion(client, admin_user):
+ """Creating a Product via admin should create an initial reversion entry."""
+
+ _login_admin(client, admin_user)
+ courserun = CourseRunFactory.create()
+ content_type = ContentType.objects.get_for_model(courserun)
+
+ response = client.post(
+ reverse("admin:ecommerce_product_add"),
+ data={
+ "content_type": content_type.id,
+ "object_id": courserun.id,
+ "price": "123.45",
+ "description": "Admin created versioned product",
+ "is_active": "on",
+ "_save": "Save",
+ },
+ )
+
+ assert response.status_code == 302
+
+ product = Product.all_objects.get(description="Admin created versioned product")
+ assert Version.objects.get_for_object(product).count() == 1
diff --git a/ecommerce/views/legacy/__init__.py b/ecommerce/views/legacy/__init__.py
index b07625a720..ddc8093b51 100644
--- a/ecommerce/views/legacy/__init__.py
+++ b/ecommerce/views/legacy/__init__.py
@@ -11,7 +11,7 @@
from django.core.exceptions import ObjectDoesNotExist
from django.db import transaction
from django.db.models import Count, Q
-from django.http import Http404, HttpResponse
+from django.http import Http404, HttpResponse, HttpResponseRedirect
from django.shortcuts import render
from django.urls import reverse
from django.utils.decorators import method_decorator
@@ -86,6 +86,16 @@
log = logging.getLogger(__name__)
+def _has_uai_b2c_program_purchase(order):
+ """Return True if the order includes a program whose readable_id contains UAI+B2C."""
+ return any(
+ isinstance(line.purchased_object, Program)
+ and line.purchased_object.readable_id
+ and "UAI+B2C" in line.purchased_object.readable_id
+ for line in order.lines.all()
+ )
+
+
class ProductsPagination(RefinePagination):
default_limit = 2
@@ -756,6 +766,9 @@ def post_checkout_redirect(self, order_state, order, request):
reverse("cart"), {"type": USER_MSG_TYPE_PAYMENT_DECLINED}
)
elif order_state == OrderStatus.FULFILLED:
+ if _has_uai_b2c_program_purchase(order):
+ return HttpResponseRedirect(settings.MIT_LEARN_DASHBOARD_URL)
+
return redirect_with_user_message(
reverse("user-dashboard"),
{
diff --git a/ecommerce/views/legacy/views_test.py b/ecommerce/views/legacy/views_test.py
index fc7dc1bcad..1fd97593ae 100644
--- a/ecommerce/views/legacy/views_test.py
+++ b/ecommerce/views/legacy/views_test.py
@@ -652,6 +652,45 @@ 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_program_to_learn_dashboard(
+ settings,
+ user,
+ user_client,
+ mocker,
+):
+ """Accepted UAI+B2C program 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,
+ )
+ mocker.patch("courses.api.create_program_enrollments")
+
+ program = ProgramFactory.create(readable_id="program-v1:MITx+UAI+B2C+2026")
+ with reversion.create_revision():
+ product = ProductFactory.create(purchasable_object=program)
+
+ create_basket_with_product(user, product)
+
+ resp = user_client.post(reverse("checkout_api-start_checkout"))
+
+ payload = resp.json()["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
+ assert resp.url == settings.MIT_LEARN_DASHBOARD_URL
+
+
@pytest.mark.parametrize(
"cart_exists, cart_empty", # noqa: PT006
[(True, False), (True, True), (False, True)],
diff --git a/frontend/public/src/components/ProgramProductDetailEnroll.js b/frontend/public/src/components/ProgramProductDetailEnroll.js
index e95ece4b7b..87f4227365 100644
--- a/frontend/public/src/components/ProgramProductDetailEnroll.js
+++ b/frontend/public/src/components/ProgramProductDetailEnroll.js
@@ -22,6 +22,7 @@ import {
programsQueryKey
} from "../lib/queries/courseRuns"
import {
+ programEnrollmentQuery,
programEnrollmentsQuery,
programEnrollmentsQueryKey,
programEnrollmentsSelector
@@ -48,6 +49,7 @@ type Props = {
programStatus: ?number,
upgradeEnrollmentDialogVisibility: boolean,
addProductToBasket: (user: number, productId: number) => Promise,
+ createProgramEnrollment: (programId: number) => Promise,
currentUser: User,
updateAddlFields: (currentUser: User) => Promise,
programEnrollments: ?Array,
@@ -130,12 +132,23 @@ export class ProgramProductDetailEnroll extends React.Component<
}
}
- toggleUpgradeDialogVisibility = () => {
+ toggleUpgradeDialogVisibility = async () => {
const { upgradeEnrollmentDialogVisibility } = this.state
+ const { programs, createProgramEnrollment } = this.props
this.setState({
upgradeEnrollmentDialogVisibility: !upgradeEnrollmentDialogVisibility
})
+ try {
+ //find program id
+ const program = programs && programs[0]
+ if (program) {
+ await createProgramEnrollment(program.id)
+ }
+ } catch (error) {
+ console.error("Failed to create program enrollment", error)
+ // Optionally, display an error message to the user.
+ }
}
setCurrentCourseRun = (runId: string) => {
@@ -470,6 +483,9 @@ const updateAddlFields = (currentUser: User) => {
return mutateAsync(users.editProfileMutation(updatedUser))
}
+const createProgramEnrollment = (programId: number) =>
+ mutateAsync(programEnrollmentQuery(programId))
+
const mapStateToProps = createStructuredSelector({
courseRuns: courseRunsSelector,
programs: programsSelector,
@@ -495,7 +511,8 @@ const mapPropsToConfig = props => [
]
const mapDispatchToProps = {
- updateAddlFields
+ updateAddlFields,
+ createProgramEnrollment
}
export default compose(
diff --git a/frontend/public/src/lib/queries/enrollment.js b/frontend/public/src/lib/queries/enrollment.js
index 6846450956..0f64c1d784 100644
--- a/frontend/public/src/lib/queries/enrollment.js
+++ b/frontend/public/src/lib/queries/enrollment.js
@@ -187,3 +187,16 @@ export const enrollmentMutation = (runId: number) => ({
},
update: {}
})
+
+export const programEnrollmentQuery = (programId: number) => ({
+ url: `/api/v3/program_enrollments/`,
+ body: {
+ program_id: `${programId}`,
+ isapi: true
+ },
+ options: {
+ ...getCsrfOptions(),
+ method: "POST"
+ },
+ update: {}
+})
diff --git a/main/apps.py b/main/apps.py
index bc6f08765b..80b9cf6b3e 100644
--- a/main/apps.py
+++ b/main/apps.py
@@ -16,7 +16,3 @@ def ready(self):
envs.validate()
configure()
-
- from main.telemetry import configure_opentelemetry # noqa: PLC0415
-
- configure_opentelemetry()
diff --git a/main/settings.py b/main/settings.py
index 89ee2fe56a..2b1e58b2a9 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.143.4"
+VERSION = "1.144.0"
log = logging.getLogger()
@@ -273,6 +273,7 @@
"micromasters_import",
# ol-django apps, must be after this project's apps for template precedence
"mitol.common.apps.CommonApp",
+ "mitol.observability.apps.ObservabilityConfig",
"mitol.google_sheets.apps.GoogleSheetsApp",
"mitol.google_sheets_refunds.apps.GoogleSheetsRefundsApp",
"mitol.google_sheets_deferrals.apps.GoogleSheetsDeferralsApp",
@@ -694,49 +695,26 @@
name="DJANGO_LOG_LEVEL", default="INFO", description="The log level for django"
)
+# mitol-django-observability reads LOG_LEVEL directly from os.environ; bridge the
+# legacy MITX_ONLINE_LOG_LEVEL env var so operators don't silently lose log control.
+os.environ.setdefault("LOG_LEVEL", LOG_LEVEL)
+
HOSTNAME = platform.node().split(".")[0]
-LOGGING = {
- "version": 1,
- "disable_existing_loggers": False,
- "filters": {"require_debug_false": {"()": "django.utils.log.RequireDebugFalse"}},
- "formatters": {
- "verbose": {
- "format": (
- "[%(asctime)s] %(levelname)s %(process)d [%(name)s] "
- "%(filename)s:%(lineno)d - "
- f"[{HOSTNAME}] - %(message)s"
- ),
- "datefmt": "%Y-%m-%d %H:%M:%S",
- }
- },
- "handlers": {
- "console": {
- "level": "DEBUG",
- "class": "logging.StreamHandler",
- "formatter": "verbose",
- },
- "mail_admins": {
- "level": "ERROR",
- "filters": ["require_debug_false"],
- "class": "django.utils.log.AdminEmailHandler",
- },
- },
- "loggers": {
- "django": {
- "propagate": True,
- "level": DJANGO_LOG_LEVEL,
- "handlers": ["console"],
- },
- "django.request": {
- "handlers": ["mail_admins"],
- "level": DJANGO_LOG_LEVEL,
- "propagate": True,
- },
- "zeal": {"handlers": ["console"], "level": "ERROR"},
- },
- "root": {"handlers": ["console"], "level": LOG_LEVEL},
+# LOGGING is provided by mitol-django-observability (structlog-based, JSON in prod)
+from mitol.observability.settings.logging import LOGGING # noqa: E402
+
+# Restore the AdminEmailHandler so Django still emails ADMINS on unhandled 500
+# errors in production (i.e. when DEBUG=False). Sentry captures errors too, but
+# this provides an independent email-based safety net.
+LOGGING.setdefault("filters", {})
+LOGGING["filters"]["require_debug_false"] = {"()": "django.utils.log.RequireDebugFalse"}
+LOGGING["handlers"]["mail_admins"] = {
+ "level": "ERROR",
+ "filters": ["require_debug_false"],
+ "class": "django.utils.log.AdminEmailHandler",
}
+LOGGING["loggers"]["django.request"]["handlers"].append("mail_admins")
# server-status
STATUS_TOKEN = get_string(
diff --git a/main/telemetry.py b/main/telemetry.py
deleted file mode 100644
index 89b2af41ce..0000000000
--- a/main/telemetry.py
+++ /dev/null
@@ -1,100 +0,0 @@
-"""OpenTelemetry initialization and configuration for MITxOnline."""
-
-from __future__ import annotations
-
-import logging
-
-from django.conf import settings
-from opentelemetry import trace
-from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import (
- OTLPSpanExporter as OTLPSpanExporterGrpc,
-)
-from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
-from opentelemetry.instrumentation.celery import CeleryInstrumentor
-from opentelemetry.instrumentation.django import DjangoInstrumentor
-from opentelemetry.instrumentation.psycopg import PsycopgInstrumentor
-from opentelemetry.instrumentation.redis import RedisInstrumentor
-from opentelemetry.instrumentation.requests import RequestsInstrumentor
-from opentelemetry.sdk.resources import Resource
-from opentelemetry.sdk.trace import TracerProvider
-from opentelemetry.sdk.trace.export import BatchSpanProcessor, ConsoleSpanExporter
-
-log = logging.getLogger(__name__)
-
-
-def configure_opentelemetry() -> TracerProvider | None:
- """
- Configure OpenTelemetry with appropriate instrumentations and exporters.
- Returns the tracer provider if configured, None otherwise.
- """
- if not getattr(settings, "OPENTELEMETRY_ENABLED", False):
- log.info("OpenTelemetry is disabled")
- return None
-
- log.info("Initializing OpenTelemetry")
-
- # Create a resource with service info
- resource = Resource.create(
- {
- "service.name": getattr(
- settings, "OPENTELEMETRY_SERVICE_NAME", "mitxonline"
- ),
- "service.version": getattr(settings, "VERSION", "unknown"),
- "deployment.environment": settings.ENVIRONMENT,
- }
- )
-
- # Configure the tracer provider
- tracer_provider = TracerProvider(resource=resource)
- trace.set_tracer_provider(tracer_provider)
-
- # Add console exporter for development/testing
- if settings.DEBUG:
- log.info("Adding console exporter for OpenTelemetry")
- console_exporter = ConsoleSpanExporter()
- tracer_provider.add_span_processor(BatchSpanProcessor(console_exporter))
-
- # Add OTLP exporter if configured
- otlp_endpoint = getattr(settings, "OPENTELEMETRY_ENDPOINT", None)
- if otlp_endpoint:
- log.info("Configuring OTLP exporter to endpoint: %s", otlp_endpoint)
-
- headers = {}
-
- try:
- use_grpc = getattr(settings, "OPENTELEMETRY_USE_GRPC", False)
- if use_grpc:
- otlp_exporter = OTLPSpanExporterGrpc(
- endpoint=otlp_endpoint,
- headers=headers,
- insecure=getattr(settings, "OPENTELEMETRY_INSECURE", True),
- )
- else:
- otlp_exporter = OTLPSpanExporter(
- endpoint=otlp_endpoint,
- headers=headers,
- )
-
- tracer_provider.add_span_processor(
- BatchSpanProcessor(
- otlp_exporter,
- max_export_batch_size=getattr(
- settings, "OPENTELEMETRY_BATCH_SIZE", 512
- ),
- schedule_delay_millis=getattr(
- settings, "OPENTELEMETRY_EXPORT_TIMEOUT_MS", 5000
- ),
- )
- )
- except Exception:
- log.exception("Failed to configure OTLP exporter")
-
- # Initialize instrumentations
- DjangoInstrumentor().instrument()
- PsycopgInstrumentor().instrument()
- RedisInstrumentor().instrument()
- CeleryInstrumentor().instrument()
- RequestsInstrumentor().instrument()
-
- log.info("OpenTelemetry initialized successfully")
- return tracer_provider
diff --git a/openapi/specs/v0.yaml b/openapi/specs/v0.yaml
index 80ca5aac65..ccbc94a078 100644
--- a/openapi/specs/v0.yaml
+++ b/openapi/specs/v0.yaml
@@ -3045,11 +3045,7 @@ paths:
Some special handling is needed for program-related course run enrollments
when the learner has an enrollment in the program. The learner should get a
course run enrollment that matches their program enrollment at no additional
- charge. However, if the learner is enrolling in a course that's an elective,
- and they have already enrolled in enough electives to satisfy the program's
- requirements, they should then get an audit enrollment. (This won't preclude
- them from getting a certificate for the course itself but they'll have to buy
- the upgrade separately.)
+ charge.
parameters:
- in: path
name: courserun_id
@@ -6533,6 +6529,10 @@ components:
live:
type: boolean
readOnly: true
+ include_in_learn_catalog:
+ type: boolean
+ nullable: true
+ description: If true, Learn should include this in its catalog.
length:
type: string
description: A short description indicating how long it takes to complete
diff --git a/openapi/specs/v1.yaml b/openapi/specs/v1.yaml
index 4e4439ed4c..9e9ef080fd 100644
--- a/openapi/specs/v1.yaml
+++ b/openapi/specs/v1.yaml
@@ -3045,11 +3045,7 @@ paths:
Some special handling is needed for program-related course run enrollments
when the learner has an enrollment in the program. The learner should get a
course run enrollment that matches their program enrollment at no additional
- charge. However, if the learner is enrolling in a course that's an elective,
- and they have already enrolled in enough electives to satisfy the program's
- requirements, they should then get an audit enrollment. (This won't preclude
- them from getting a certificate for the course itself but they'll have to buy
- the upgrade separately.)
+ charge.
parameters:
- in: path
name: courserun_id
@@ -6533,6 +6529,10 @@ components:
live:
type: boolean
readOnly: true
+ include_in_learn_catalog:
+ type: boolean
+ nullable: true
+ description: If true, Learn should include this in its catalog.
length:
type: string
description: A short description indicating how long it takes to complete
diff --git a/openapi/specs/v2.yaml b/openapi/specs/v2.yaml
index 803ab3c727..357f68b9e6 100644
--- a/openapi/specs/v2.yaml
+++ b/openapi/specs/v2.yaml
@@ -3045,11 +3045,7 @@ paths:
Some special handling is needed for program-related course run enrollments
when the learner has an enrollment in the program. The learner should get a
course run enrollment that matches their program enrollment at no additional
- charge. However, if the learner is enrolling in a course that's an elective,
- and they have already enrolled in enough electives to satisfy the program's
- requirements, they should then get an audit enrollment. (This won't preclude
- them from getting a certificate for the course itself but they'll have to buy
- the upgrade separately.)
+ charge.
parameters:
- in: path
name: courserun_id
@@ -6533,6 +6529,10 @@ components:
live:
type: boolean
readOnly: true
+ include_in_learn_catalog:
+ type: boolean
+ nullable: true
+ description: If true, Learn should include this in its catalog.
length:
type: string
description: A short description indicating how long it takes to complete
diff --git a/openedx/api.py b/openedx/api.py
index 52ab716d2e..73aa586124 100644
--- a/openedx/api.py
+++ b/openedx/api.py
@@ -247,9 +247,27 @@ def _handle_username_collision( # noqa: PLR0913
if suggestions:
suggested_usernames = suggestions
+ # Filter out any suggestions that are already taken locally in MITx Online.
+ # edX does not know about our local username uniqueness constraint, so its
+ # suggestions may collide with existing OpenEdxUser records here.
+ if suggested_usernames:
+ locally_taken = set(
+ OpenEdxUser.objects.filter(edx_username__in=suggested_usernames)
+ .exclude(user=user)
+ .values_list("edx_username", flat=True)
+ )
+ if locally_taken:
+ log.debug(
+ "Skipping OpenEdX suggestions already taken locally: %s",
+ locally_taken,
+ )
+ suggested_usernames = [
+ u for u in suggested_usernames if u not in locally_taken
+ ]
+
if not suggested_usernames:
log.info(
- "OpenEdX returned empty username suggestions, falling back to local generation"
+ "OpenEdX returned no locally available username suggestions, falling back to local generation"
)
base_username = open_edx_user.desired_edx_username
if not base_username:
diff --git a/openedx/api_test.py b/openedx/api_test.py
index 536816a68d..a6f709917a 100644
--- a/openedx/api_test.py
+++ b/openedx/api_test.py
@@ -478,6 +478,59 @@ def test_create_edx_user_conflict( # noqa: PLR0913
)
+@responses.activate
+@pytest.mark.usefixtures("application")
+def test_create_edx_user_conflict_suggested_username_taken_locally(settings):
+ """
+ When edX suggests a username that is already taken locally
+ in MITx Online (e.g., jeff_8 exists in OpenEdxUser), the app must not blindly
+ use it (which would cause a UniqueViolation). Instead it should skip that
+ suggestion and fall back to generating a locally unique username.
+ """
+ user = UserFactory.create(
+ openedx_user__has_been_synced=False,
+ openedx_user__desired_edx_username="jeff",
+ )
+ # Simulate jeff_8 already existing locally in MITx Online
+ UserFactory.create(openedx_user__edx_username="jeff_8")
+
+ responses.add(
+ responses.GET,
+ f"{settings.OPENEDX_API_BASE_URL}/api/mobile/v0.5/my_user_info",
+ json={},
+ status=status.HTTP_200_OK,
+ )
+ # edX returns a 409 with its only suggestion being jeff_8, which is already taken locally
+ responses.add(
+ responses.POST,
+ f"{settings.OPENEDX_API_BASE_URL}/user_api/v1/account/registration/",
+ json={
+ "error_code": "duplicate-username",
+ "username_suggestions": ["jeff_8"],
+ },
+ status=status.HTTP_409_CONFLICT,
+ )
+ # Second registration attempt with the locally-generated username succeeds
+ responses.add(
+ responses.POST,
+ f"{settings.OPENEDX_API_BASE_URL}/user_api/v1/account/registration/",
+ json=dict(success=True), # noqa: C408
+ status=status.HTTP_200_OK,
+ )
+ edx_username_validation_response_mock(False, settings) # noqa: FBT003
+
+ create_edx_user(user)
+
+ user.refresh_from_db()
+ edx_user = user.openedx_users.first()
+
+ assert edx_user.has_been_synced is True
+ # Must not have used the locally-conflicting suggestion
+ assert edx_user.edx_username != "jeff_8"
+ # Should have generated a username based on the base username
+ assert edx_user.edx_username.startswith("jeff_")
+
+
@pytest.mark.parametrize(
("base_username", "expected_prefix"),
[
diff --git a/pyproject.toml b/pyproject.toml
index 077f090666..a35541aa46 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -47,14 +47,12 @@ dependencies = [
"mitol-django-openedx==2023.12.19",
"mitol-django-payment-gateway==2023.12.19",
"mitol-django-scim>=2025.5.23",
- "opentelemetry-api>=1.31.0",
- "opentelemetry-exporter-otlp>=1.31.0",
+ "mitol-django-observability>=2026.1.0",
"opentelemetry-instrumentation-celery>=0.52b0",
"opentelemetry-instrumentation-django>=0.52b0",
"opentelemetry-instrumentation-psycopg>=0.52b0",
"opentelemetry-instrumentation-redis>=0.52b0",
"opentelemetry-instrumentation-requests>=0.52b0",
- "opentelemetry-sdk>=1.31.0",
"psycopg>=3.2.4,<4",
"psycopg2>=2.9.5,<3",
"pyOpenSSL>=26,<27",
diff --git a/uv.lock b/uv.lock
index 56932accec..297c35c6ff 100644
--- a/uv.lock
+++ b/uv.lock
@@ -1820,6 +1820,25 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/5e/45/27afcb7a712c72cc3f4bbadfd696fe03c747d9fc35582b610cd0fa25b07f/mitol_django_mail-2023.12.19-py3-none-any.whl", hash = "sha256:a32853bfe7da39d4c34651d7e5bddc547678c1b5b3a54e56e492879276eab371", size = 19714, upload-time = "2023-12-19T18:14:25.682Z" },
]
+[[package]]
+name = "mitol-django-observability"
+version = "2026.3.11"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "django" },
+ { name = "mitol-django-common" },
+ { name = "opentelemetry-api" },
+ { name = "opentelemetry-exporter-otlp-proto-grpc" },
+ { name = "opentelemetry-exporter-otlp-proto-http" },
+ { name = "opentelemetry-sdk" },
+ { name = "pyyaml" },
+ { name = "structlog" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/84/3b/8c07a0a0feda332656213a8af4f055fa83fb9e0d6d483d77f12d8e63b47e/mitol_django_observability-2026.3.11.tar.gz", hash = "sha256:4539eff6b7da18e500fb0f23c0abf6bdba67eb65e35e890b08c338318494a1e6", size = 10093, upload-time = "2026-03-13T20:21:41.271Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/cf/76/3f730dec3ff4ff6c5ab044d7b1e027b6f3214cedb66a2532301b6eb4b285/mitol_django_observability-2026.3.11-py3-none-any.whl", hash = "sha256:7523782802c09cad3003cacecdb62cc055efdbd8a80a9ea27a12cb5aaf3f59bc", size = 16052, upload-time = "2026-03-13T20:21:40.216Z" },
+]
+
[[package]]
name = "mitol-django-olposthog"
version = "2026.3.6"
@@ -1933,18 +1952,16 @@ dependencies = [
{ name = "mitol-django-google-sheets-refunds" },
{ name = "mitol-django-hubspot-api" },
{ name = "mitol-django-mail" },
+ { name = "mitol-django-observability" },
{ name = "mitol-django-olposthog" },
{ name = "mitol-django-openedx" },
{ name = "mitol-django-payment-gateway" },
{ name = "mitol-django-scim" },
- { name = "opentelemetry-api" },
- { name = "opentelemetry-exporter-otlp" },
{ name = "opentelemetry-instrumentation-celery" },
{ name = "opentelemetry-instrumentation-django" },
{ name = "opentelemetry-instrumentation-psycopg" },
{ name = "opentelemetry-instrumentation-redis" },
{ name = "opentelemetry-instrumentation-requests" },
- { name = "opentelemetry-sdk" },
{ name = "psycopg" },
{ name = "psycopg2" },
{ name = "pycountry" },
@@ -2043,18 +2060,16 @@ requires-dist = [
{ name = "mitol-django-google-sheets-refunds", specifier = ">=2025.6.13,<2026" },
{ name = "mitol-django-hubspot-api", specifier = "==2025.12.18" },
{ name = "mitol-django-mail", specifier = "==2023.12.19" },
+ { name = "mitol-django-observability", specifier = ">=2026.1.0" },
{ name = "mitol-django-olposthog", specifier = ">=2026.3.6,<2027" },
{ name = "mitol-django-openedx", specifier = "==2023.12.19" },
{ name = "mitol-django-payment-gateway", specifier = "==2023.12.19" },
{ name = "mitol-django-scim", specifier = ">=2025.5.23" },
- { name = "opentelemetry-api", specifier = ">=1.31.0" },
- { name = "opentelemetry-exporter-otlp", specifier = ">=1.31.0" },
{ name = "opentelemetry-instrumentation-celery", specifier = ">=0.52b0" },
{ name = "opentelemetry-instrumentation-django", specifier = ">=0.52b0" },
{ name = "opentelemetry-instrumentation-psycopg", specifier = ">=0.52b0" },
{ name = "opentelemetry-instrumentation-redis", specifier = ">=0.52b0" },
{ name = "opentelemetry-instrumentation-requests", specifier = ">=0.52b0" },
- { name = "opentelemetry-sdk", specifier = ">=1.31.0" },
{ name = "psycopg", specifier = ">=3.2.4,<4" },
{ name = "psycopg2", specifier = ">=2.9.5,<3" },
{ name = "pycountry", specifier = ">=24.6.1,<25" },
@@ -2170,19 +2185,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/cf/df/d3f1ddf4bb4cb50ed9b1139cc7b1c54c34a1e7ce8fd1b9a37c0d1551a6bd/opentelemetry_api-1.39.1-py3-none-any.whl", hash = "sha256:2edd8463432a7f8443edce90972169b195e7d6a05500cd29e6d13898187c9950", size = 66356, upload-time = "2025-12-11T13:32:17.304Z" },
]
-[[package]]
-name = "opentelemetry-exporter-otlp"
-version = "1.39.1"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "opentelemetry-exporter-otlp-proto-grpc" },
- { name = "opentelemetry-exporter-otlp-proto-http" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/30/9c/3ab1db90f32da200dba332658f2bbe602369e3d19f6aba394031a42635be/opentelemetry_exporter_otlp-1.39.1.tar.gz", hash = "sha256:7cf7470e9fd0060c8a38a23e4f695ac686c06a48ad97f8d4867bc9b420180b9c", size = 6147, upload-time = "2025-12-11T13:32:40.309Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/00/6c/bdc82a066e6fb1dcf9e8cc8d4e026358fe0f8690700cc6369a6bf9bd17a7/opentelemetry_exporter_otlp-1.39.1-py3-none-any.whl", hash = "sha256:68ae69775291f04f000eb4b698ff16ff685fdebe5cb52871bc4e87938a7b00fe", size = 7019, upload-time = "2025-12-11T13:32:19.387Z" },
-]
-
[[package]]
name = "opentelemetry-exporter-otlp-proto-common"
version = "1.39.1"
@@ -3095,7 +3097,7 @@ wheels = [
[[package]]
name = "requests"
-version = "2.32.5"
+version = "2.33.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
@@ -3103,9 +3105,9 @@ dependencies = [
{ name = "idna" },
{ name = "urllib3" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/34/64/8860370b167a9721e8956ae116825caff829224fbca0ca6e7bf8ddef8430/requests-2.33.0.tar.gz", hash = "sha256:c7ebc5e8b0f21837386ad0e1c8fe8b829fa5f544d8df3b2253bff14ef29d7652", size = 134232, upload-time = "2026-03-25T15:10:41.586Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" },
+ { url = "https://files.pythonhosted.org/packages/56/5d/c814546c2333ceea4ba42262d8c4d55763003e767fa169adc693bd524478/requests-2.33.0-py3-none-any.whl", hash = "sha256:3324635456fa185245e24865e810cecec7b4caf933d7eb133dcde67d48cee69b", size = 65017, upload-time = "2026-03-25T15:10:40.382Z" },
]
[[package]]
@@ -3374,6 +3376,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/81/69/297302c5f5f59c862faa31e6cb9a4cd74721cd1e052b38e464c5b402df8b/StrEnum-0.4.15-py3-none-any.whl", hash = "sha256:a30cda4af7cc6b5bf52c8055bc4bf4b2b6b14a93b574626da33df53cf7740659", size = 8851, upload-time = "2023-06-29T22:02:56.947Z" },
]
+[[package]]
+name = "structlog"
+version = "25.5.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/ef/52/9ba0f43b686e7f3ddfeaa78ac3af750292662284b3661e91ad5494f21dbc/structlog-25.5.0.tar.gz", hash = "sha256:098522a3bebed9153d4570c6d0288abf80a031dfdb2048d59a49e9dc2190fc98", size = 1460830, upload-time = "2025-10-27T08:28:23.028Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a8/45/a132b9074aa18e799b891b91ad72133c98d8042c70f6240e4c5f9dabee2f/structlog-25.5.0-py3-none-any.whl", hash = "sha256:a8453e9b9e636ec59bd9e79bbd4a72f025981b3ba0f5837aebf48f02f37a7f9f", size = 72510, upload-time = "2025-10-27T08:28:21.535Z" },
+]
+
[[package]]
name = "tabulate"
version = "0.9.0"