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

Expand Down
2 changes: 1 addition & 1 deletion cms/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {}
Expand Down
30 changes: 30 additions & 0 deletions cms/migrations/0058_programpage_include_in_learn_catalog.py
Original file line number Diff line number Diff line change
@@ -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,
),
]
16 changes: 13 additions & 3 deletions cms/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions cms/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -372,6 +372,7 @@ class Meta:
"financial_assistance_form_url",
"description",
"live",
"include_in_learn_catalog",
"length",
"effort",
"price",
Expand Down
5 changes: 5 additions & 0 deletions cms/serializers_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -411,6 +411,7 @@ def test_serialize_program_page(
"length": program_page.length,
"effort": program_page.effort,
"price": None,
"include_in_learn_catalog": False,
},
)

Expand Down Expand Up @@ -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,
},
)

Expand Down Expand Up @@ -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,
},
)

Expand Down Expand Up @@ -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,
},
)

Expand Down Expand Up @@ -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,
},
)

Expand Down
5 changes: 4 additions & 1 deletion cms/wagtail_api/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
21 changes: 0 additions & 21 deletions courses/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
39 changes: 4 additions & 35 deletions courses/api_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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()

Expand All @@ -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,
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion courses/templates/mail/enrollment_failure/body.html
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{% extend "mail/support" %}
{% extends "mail/support" %}
<p>{{enrollment_type}} #{{enrollment_obj.id}} ({{enrollment_obj.title}})
</p>
<br>
Expand Down
41 changes: 9 additions & 32 deletions courses/views/v2/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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]

Expand Down
14 changes: 2 additions & 12 deletions courses/views/v2/views_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand Down
7 changes: 7 additions & 0 deletions ecommerce/admin.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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):
Expand Down
Loading
Loading