From b9ed8f1a7502e1a946bcb7efbcbd8d40ce188ddd Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 16 Apr 2026 09:33:41 -0400 Subject: [PATCH 1/5] [pre-commit.ci] pre-commit autoupdate (#3486) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 4ccc946998..b5fda0b2ba 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -14,7 +14,7 @@ repos: - id: check-toml - id: debug-statements - repo: https://github.com/scop/pre-commit-shfmt - rev: v3.13.0-1 + rev: v3.13.1-1 hooks: - id: shfmt - repo: https://github.com/adrienverge/yamllint.git @@ -47,7 +47,7 @@ repos: - "config/keycloak/*" additional_dependencies: ["gibberish-detector"] - repo: https://github.com/astral-sh/ruff-pre-commit - rev: "v0.15.9" + rev: "v0.15.10" hooks: - id: ruff-format - id: ruff From 279142664608838160defc0b95f1aebac9aed57e Mon Sep 17 00:00:00 2001 From: Chris Chudzicki Date: Thu, 16 Apr 2026 10:36:30 -0400 Subject: [PATCH 2/5] Make enrollment_mode field non-nullable (#3494) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../0092_enrollment_mode_non_nullable.py | 22 +++++++++++++++++++ courses/models.py | 4 ++-- openapi/specs/v0.yaml | 1 - openapi/specs/v1.yaml | 1 - openapi/specs/v2.yaml | 1 - 5 files changed, 24 insertions(+), 5 deletions(-) create mode 100644 courses/migrations/0092_enrollment_mode_non_nullable.py diff --git a/courses/migrations/0092_enrollment_mode_non_nullable.py b/courses/migrations/0092_enrollment_mode_non_nullable.py new file mode 100644 index 0000000000..67697cff54 --- /dev/null +++ b/courses/migrations/0092_enrollment_mode_non_nullable.py @@ -0,0 +1,22 @@ +# Generated by Django 5.1.15 on 2026-04-15 19:39 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("courses", "0091_add_soft_delete_to_partner_schools"), + ] + + operations = [ + migrations.AlterField( + model_name="courserunenrollment", + name="enrollment_mode", + field=models.CharField(default="audit", max_length=20), + ), + migrations.AlterField( + model_name="programenrollment", + name="enrollment_mode", + field=models.CharField(default="audit", max_length=20), + ), + ] diff --git a/courses/models.py b/courses/models.py index c4dc08cb91..ea099e99b5 100644 --- a/courses/models.py +++ b/courses/models.py @@ -1692,8 +1692,8 @@ class Meta: default=True, help_text="Indicates whether or not this enrollment should be considered active", ) - enrollment_mode = models.CharField( # noqa: DJ001 - default=EDX_DEFAULT_ENROLLMENT_MODE, max_length=20, null=True, blank=True + enrollment_mode = models.CharField( + default=EDX_DEFAULT_ENROLLMENT_MODE, max_length=20 ) objects = ActiveEnrollmentManager() diff --git a/openapi/specs/v0.yaml b/openapi/specs/v0.yaml index 630a8a0996..1ad896bec6 100644 --- a/openapi/specs/v0.yaml +++ b/openapi/specs/v0.yaml @@ -9197,7 +9197,6 @@ components: nullable: true enrollment_mode: type: string - nullable: true maxLength: 20 required: - certificate diff --git a/openapi/specs/v1.yaml b/openapi/specs/v1.yaml index 4621759858..3b9fd65e9a 100644 --- a/openapi/specs/v1.yaml +++ b/openapi/specs/v1.yaml @@ -9197,7 +9197,6 @@ components: nullable: true enrollment_mode: type: string - nullable: true maxLength: 20 required: - certificate diff --git a/openapi/specs/v2.yaml b/openapi/specs/v2.yaml index 01570bb96e..980098ce31 100644 --- a/openapi/specs/v2.yaml +++ b/openapi/specs/v2.yaml @@ -9197,7 +9197,6 @@ components: nullable: true enrollment_mode: type: string - nullable: true maxLength: 20 required: - certificate From 88542bf2559305e5c4e94dd24435a20b4ba163a2 Mon Sep 17 00:00:00 2001 From: Muhammad Anas <88967643+Anas12091101@users.noreply.github.com> Date: Thu, 16 Apr 2026 19:40:30 +0500 Subject: [PATCH 3/5] feat: add webhook endpoint for Open edX course enrollment (#3372) --- courses/api.py | 35 ++++++ courses/api_test.py | 51 +++++++++ openedx/urls.py | 5 + openedx/views.py | 111 +++++++++++++++++++ openedx/views_test.py | 240 ++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 442 insertions(+) diff --git a/courses/api.py b/courses/api.py index a9659bc09b..8a202973bf 100644 --- a/courses/api.py +++ b/courses/api.py @@ -133,6 +133,41 @@ def get_user_relevant_program_course_run_qset( return enrollable_run_qset.order_by("enrollment_start") +def create_local_enrollment(user, run, *, mode=EDX_DEFAULT_ENROLLMENT_MODE): + """ + Creates a local-only CourseRunEnrollment record without calling the edX API. + Reactivates the enrollment if it already exists but is inactive, and ensures + edx_enrolled is set to True. + + This is intended for cases where the user is already enrolled in edX (e.g. + via a webhook notification) and we only need to mirror that state locally. + + Args: + user (User): The user to enroll + run (CourseRun): The course run to enroll in + mode (str): The enrollment mode (default: audit) + + Returns: + (CourseRunEnrollment, bool): The enrollment object and whether it was newly created + """ + enrollment, created = CourseRunEnrollment.all_objects.get_or_create( + user=user, + run=run, + defaults={ + "change_status": None, + "edx_enrolled": True, + "enrollment_mode": mode, + }, + ) + if not created and not enrollment.active: + enrollment.reactivate_and_save() + if not enrollment.edx_enrolled: + enrollment.edx_enrolled = True + enrollment.save_and_log(None) + + return enrollment, created + + def create_run_enrollments( # noqa: C901 user, runs, diff --git a/courses/api_test.py b/courses/api_test.py index dd4298724b..0f08e1862b 100644 --- a/courses/api_test.py +++ b/courses/api_test.py @@ -34,6 +34,7 @@ from cms.factories import CourseIndexPageFactory from courses.api import ( check_course_modes, + create_local_enrollment, create_program_enrollments, create_run_enrollments, deactivate_run_enrollment, @@ -186,6 +187,56 @@ def _mock_edx_course_detail(coursekey, settings): } +@pytest.mark.parametrize( + "enrollment_mode", [EDX_DEFAULT_ENROLLMENT_MODE, EDX_ENROLLMENT_VERIFIED_MODE] +) +def test_create_local_enrollment_new(user, enrollment_mode): + """ + create_local_enrollment should create a new CourseRunEnrollment with edx_enrolled=True + and the specified mode, without calling the edX API. + """ + run = CourseRunFactory.create() + + enrollment, created = create_local_enrollment(user, run, mode=enrollment_mode) + + assert created is True + assert enrollment.user == user + assert enrollment.run == run + assert enrollment.active is True + assert enrollment.edx_enrolled is True + assert enrollment.enrollment_mode == enrollment_mode + assert enrollment.change_status is None + + +@pytest.mark.parametrize( + ("existing_active", "existing_edx_enrolled"), + [ + (False, False), + (True, True), + ], +) +def test_create_local_enrollment_existing_enrollment( + user, + existing_active, + existing_edx_enrolled, +): + """create_local_enrollment should be idempotent and reactivate when needed.""" + run = CourseRunFactory.create() + existing = CourseRunEnrollmentFactory.create( + user=user, + run=run, + active=existing_active, + edx_enrolled=existing_edx_enrolled, + ) + + enrollment, created = create_local_enrollment(user, run) + + assert created is False + assert enrollment.id == existing.id + assert enrollment.active is True + assert enrollment.edx_enrolled is True + + @pytest.mark.parametrize( "enrollment_mode", [EDX_DEFAULT_ENROLLMENT_MODE, EDX_ENROLLMENT_VERIFIED_MODE] ) diff --git a/openedx/urls.py b/openedx/urls.py index 782b2c5bf0..ec5a30784d 100644 --- a/openedx/urls.py +++ b/openedx/urls.py @@ -15,4 +15,9 @@ views.openedx_private_auth_complete, name="openedx-private-oauth-complete-no-apisix", ), + path( + "api/openedx_webhook/enrollment/", + views.edx_enrollment_webhook, + name="openedx-enrollment-webhook", + ), ) diff --git a/openedx/views.py b/openedx/views.py index e41b86ec6b..dc3db688ab 100644 --- a/openedx/views.py +++ b/openedx/views.py @@ -1,10 +1,121 @@ """Views for openedx""" +import logging + from django.http import HttpResponse +from drf_spectacular.utils import extend_schema +from oauth2_provider.contrib.rest_framework import OAuth2Authentication from rest_framework import status +from rest_framework.decorators import ( + api_view, + authentication_classes, + permission_classes, +) +from rest_framework.permissions import IsAdminUser +from rest_framework.response import Response + +from courses.api import create_local_enrollment +from courses.models import CourseRun +from users.models import User + +log = logging.getLogger(__name__) def openedx_private_auth_complete(request): # noqa: ARG001 """Responds with a simple HTTP_200_OK""" # NOTE: this is only meant as a landing endpoint for api.create_edx_auth_token() flow return HttpResponse(status=status.HTTP_200_OK) + + +@extend_schema(exclude=True) +@api_view(["POST"]) +@authentication_classes([OAuth2Authentication]) +@permission_classes([IsAdminUser]) +def edx_enrollment_webhook(request): + """ + Webhook endpoint that receives enrollment notifications from Open edX. + + When a user needs to be enrolled in a course (e.g., staff/instructor role added), + the Open edX plugin POSTs to this endpoint so MITx Online can enroll them as an + auditor in the corresponding course run. + + Authentication: OAuth2 Bearer token (Django OAuth Toolkit access token). + + Expected payload: + { + "email": "instructor@example.com", + "course_id": "course-v1:MITx+1.001x+2025_T1", + "role": "instructor" + } + """ + # --- Validate payload --- + email = request.data.get("email") + course_id = request.data.get("course_id") + role = request.data.get("role", "") + + if not email or not course_id: + return Response( + {"error": "Missing required fields: email and course_id"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # --- Look up user --- + try: + user = User.objects.get(email__iexact=email) + except User.DoesNotExist: + log.warning( + "Webhook: No user found with email %s for course %s (role: %s)", + email, + course_id, + role, + ) + return Response( + {"error": "User not found"}, + status=status.HTTP_404_NOT_FOUND, + ) + + # --- Look up course run --- + try: + course_run = CourseRun.objects.get(courseware_id=course_id) + except CourseRun.DoesNotExist: + log.warning( + "Webhook: No course run found with courseware_id %s (user: %s, role: %s)", + course_id, + email, + role, + ) + return Response( + {"error": f"Course run with id {course_id} not found"}, + status=status.HTTP_404_NOT_FOUND, + ) + + # --- Create local enrollment --- + try: + enrollment, created = create_local_enrollment(user, course_run) + except Exception: + log.exception( + "Webhook: Error creating enrollment for user %s in course run %s", + email, + course_id, + ) + return Response( + {"error": "Failed to create enrollment"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + log.info( + "Webhook: Successfully enrolled user %s in course run %s as auditor (role: %s, created: %s)", + email, + course_id, + role, + created, + ) + return Response( + { + "message": "Enrollment successful", + "enrollment_id": enrollment.id, + "active": enrollment.active, + "edx_enrolled": enrollment.edx_enrolled, + }, + status=status.HTTP_201_CREATED if created else status.HTTP_200_OK, + ) diff --git a/openedx/views_test.py b/openedx/views_test.py index 7b8d69ac86..4e8b277825 100644 --- a/openedx/views_test.py +++ b/openedx/views_test.py @@ -1,11 +1,24 @@ """Test openedx views""" +from datetime import timedelta +from unittest.mock import patch + import pytest from django.shortcuts import reverse +from mitol.common.utils.datetime import now_in_utc +from oauth2_provider.models import AccessToken, Application +from oauthlib.common import generate_token from rest_framework import status +from rest_framework.test import APIClient + +from courses.factories import CourseRunFactory +from courses.models import CourseRunEnrollment +from users.factories import UserFactory pytestmark = [pytest.mark.django_db] +WEBHOOK_URL = "openedx-enrollment-webhook" + @pytest.mark.parametrize( "route", @@ -18,3 +31,230 @@ def test_openedx_private_auth_complete_view(client, route): """Verify the openedx_private_auth_complete view returns a 200""" response = client.get(reverse(route)) assert response.status_code == status.HTTP_200_OK + + +class TestEdxEnrollmentWebhook: + """Tests for the edx_enrollment_webhook view""" + + @pytest.fixture + def api_client(self): + """Unauthenticated API client""" + return APIClient() + + @pytest.fixture + def oauth_application(self): + """Create an OAuth2 application""" + return Application.objects.create( + name="edx-oauth-app", + client_type=Application.CLIENT_CONFIDENTIAL, + authorization_grant_type=Application.GRANT_CLIENT_CREDENTIALS, + ) + + @pytest.fixture + def oauth_token(self, oauth_application): + """Create a valid OAuth2 access token""" + user = UserFactory.create(is_staff=True) + return AccessToken.objects.create( + user=user, + application=oauth_application, + token=generate_token(), + expires=now_in_utc() + timedelta(hours=1), + ) + + @pytest.fixture + def non_staff_oauth_token(self, oauth_application): + """Create a valid OAuth2 access token for a non-staff user""" + user = UserFactory.create(is_staff=False) + return AccessToken.objects.create( + user=user, + application=oauth_application, + token=generate_token(), + expires=now_in_utc() + timedelta(hours=1), + ) + + @pytest.fixture + def expired_oauth_token(self, oauth_application): + """Create an expired OAuth2 access token""" + user = UserFactory.create(is_staff=True) + return AccessToken.objects.create( + user=user, + application=oauth_application, + token=generate_token(), + expires=now_in_utc() - timedelta(hours=1), + ) + + @pytest.fixture + def webhook_payload(self): + """Standard webhook payload""" + return { + "email": "instructor@example.com", + "course_id": "course-v1:MITx+1.001x+2025_T1", + "role": "instructor", + } + + def _post_webhook(self, api_client, payload, token=None): + """Helper to POST to the webhook with OAuth2 Bearer auth""" + headers = {} + if token is not None: + headers["HTTP_AUTHORIZATION"] = f"Bearer {token}" + return api_client.post( + reverse(WEBHOOK_URL), + data=payload, + format="json", + **headers, + ) + + @pytest.mark.parametrize("role", ["instructor", "staff"]) + def test_successful_enrollment(self, api_client, oauth_token, role): + """Test successful enrollment of a user as auditor via webhook""" + user = UserFactory.create() + course_run = CourseRunFactory.create() + + payload = { + "email": user.email, + "course_id": course_run.courseware_id, + "role": role, + } + response = self._post_webhook(api_client, payload, token=oauth_token.token) + + assert response.status_code == status.HTTP_201_CREATED + assert response.data["message"] == "Enrollment successful" + assert response.data["edx_enrolled"] is True + + enrollment = CourseRunEnrollment.all_objects.get(user=user, run=course_run) + assert enrollment.active is True + assert enrollment.edx_enrolled is True + assert enrollment.enrollment_mode == "audit" + + @pytest.mark.parametrize( + ("auth_scenario", "expected_status"), + [ + ("none", status.HTTP_401_UNAUTHORIZED), + ("invalid", status.HTTP_401_UNAUTHORIZED), + ("expired", status.HTTP_401_UNAUTHORIZED), + ("non_staff", status.HTTP_403_FORBIDDEN), + ], + ) + def test_authentication_and_permission_failures( + self, request, api_client, webhook_payload, auth_scenario, expected_status + ): + """Test that invalid/missing/expired tokens return 401 and non-staff returns 403""" + token_map = { + "none": None, + "invalid": "invalid-token", + "expired": request.getfixturevalue("expired_oauth_token").token, + "non_staff": request.getfixturevalue("non_staff_oauth_token").token, + } + response = self._post_webhook( + api_client, webhook_payload, token=token_map[auth_scenario] + ) + assert response.status_code == expected_status + + @pytest.mark.parametrize("missing_field", ["email", "course_id"]) + def test_missing_required_field(self, api_client, oauth_token, missing_field): + """Test request missing a required field returns 400""" + payload = { + "email": "instructor@example.com", + "course_id": "course-v1:MITx+1.001x+2025_T1", + "role": "staff", + } + del payload[missing_field] + response = self._post_webhook(api_client, payload, token=oauth_token.token) + assert response.status_code == status.HTTP_400_BAD_REQUEST + + @pytest.mark.parametrize( + ("create_user", "create_course_run"), + [(False, True), (True, False)], + ids=["user_not_found", "course_run_not_found"], + ) + def test_resource_not_found( + self, api_client, oauth_token, create_user, create_course_run + ): + """Test returns 404 when user or course run doesn't exist""" + email = "nonexistent@example.com" + course_id = "course-v1:MITx+NONEXISTENT+2025_T1" + + if create_user: + user = UserFactory.create() + email = user.email + if create_course_run: + course_run = CourseRunFactory.create() + course_id = course_run.courseware_id + + payload = {"email": email, "course_id": course_id, "role": "instructor"} + response = self._post_webhook(api_client, payload, token=oauth_token.token) + assert response.status_code == status.HTTP_404_NOT_FOUND + assert "not found" in response.data["error"] + + @patch( + "openedx.views.create_local_enrollment", + side_effect=Exception("Unexpected error"), + ) + def test_enrollment_creation_exception( + self, + mock_create_local, # noqa: ARG002 + api_client, + oauth_token, + ): + """Test returns 500 when enrollment creation raises an exception""" + user = UserFactory.create() + course_run = CourseRunFactory.create() + + payload = { + "email": user.email, + "course_id": course_run.courseware_id, + "role": "instructor", + } + response = self._post_webhook(api_client, payload, token=oauth_token.token) + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "Failed to create enrollment" in response.data["error"] + + def test_already_enrolled_user(self, api_client, oauth_token): + """Test that webhook succeeds for an already-enrolled user (idempotent)""" + user = UserFactory.create() + course_run = CourseRunFactory.create() + CourseRunEnrollment.all_objects.create( + user=user, + run=course_run, + edx_enrolled=True, + enrollment_mode="audit", + ) + + payload = { + "email": user.email, + "course_id": course_run.courseware_id, + "role": "instructor", + } + response = self._post_webhook(api_client, payload, token=oauth_token.token) + + assert response.status_code == status.HTTP_200_OK + assert response.data["message"] == "Enrollment successful" + assert ( + CourseRunEnrollment.all_objects.filter(user=user, run=course_run).count() + == 1 + ) + + def test_no_edx_api_call(self, api_client, oauth_token): + """Test that the webhook does NOT call back to edX API""" + user = UserFactory.create() + course_run = CourseRunFactory.create() + + payload = { + "email": user.email, + "course_id": course_run.courseware_id, + "role": "instructor", + } + + with patch("openedx.api.enroll_in_edx_course_runs") as mock_edx_enroll: + response = self._post_webhook(api_client, payload, token=oauth_token.token) + mock_edx_enroll.assert_not_called() + + assert response.status_code == status.HTTP_201_CREATED + + def test_get_method_not_allowed(self, api_client, oauth_token): + """Test that GET requests are rejected""" + response = api_client.get( + reverse(WEBHOOK_URL), + HTTP_AUTHORIZATION=f"Bearer {oauth_token.token}", + ) + assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED From 4890c34a316b7118c2ed6f6597b4ae1a6e59031b Mon Sep 17 00:00:00 2001 From: James Kachel Date: Thu, 16 Apr 2026 12:15:20 -0500 Subject: [PATCH 4/5] Add fields for configuring the How You'll Learn cards on product pages (#3487) --- cms/constants.py | 40 ++++++ cms/migrations/0060_add_hyl_choice_fields.py | 132 +++++++++++++++++++ cms/models.py | 90 ++++++++++++- cms/wagtail_api/schema/serializers.py | 13 ++ openapi/specs/v0.yaml | 27 ++++ openapi/specs/v1.yaml | 27 ++++ openapi/specs/v2.yaml | 27 ++++ 7 files changed, 355 insertions(+), 1 deletion(-) create mode 100644 cms/migrations/0060_add_hyl_choice_fields.py diff --git a/cms/constants.py b/cms/constants.py index 0894f4d1c3..030ca99ae7 100644 --- a/cms/constants.py +++ b/cms/constants.py @@ -12,3 +12,43 @@ ONE_MINUTE = 60 FEATURED_ITEMS_CACHE_KEY = "CMS_homepage_featured_courses" + +HYL_CHOICE_REALWORLD_LEARNING = { + "icon": "IconConnectedPeople", + "title": "Real-World Learning", + "text": "Learn from MIT faculty and experts who ground their teaching in real-world cases rather than mathematical models, making the material approachable for all.", +} +HYL_CHOICE_LEARN_BY_DOING = { + "icon": "IconBrains", + "title": "Practical Application", + "text": "Apply your new knowledge with hands-on, practical exercises drawn from healthcare, sports, finance, sustainability, and more.", +} +HYL_CHOICE_LEARN_FROM_OTHERS = { + "icon": "IconBrains", + "title": "Learn From Others", + "text": "Connect with an international community of professionals working on real-world projects.", +} +HYL_CHOICE_LEARN_ON_DEMAND = { + "icon": "IconBrains", + "title": "Learn On Demand", + "text": "Access all course content online with complete flexibility to study at your own pace.", +} +HYL_CHOICE_AI_ENABLED_SUPPORT = { + "icon": "IconComputerBulb", + "title": "AI-Enabled Support", + "text": "Deepen your understanding of the course material and get help on assignments from AskTIM, the AI assistant built by MIT researchers.", +} +HYL_CHOICE_STACKABLE_CREDENTIALS = { + "icon": "IconCertificate", + "title": "Stackable Credentials", + "text": "Earn an MIT Open Learning certificate at each milestone—module, course, and program—demonstrating your AI expertise. Available in paid courses only.", +} + +HYL_CHOICES = { + "realworld_learning": HYL_CHOICE_REALWORLD_LEARNING, + "learn_by_doing": HYL_CHOICE_LEARN_BY_DOING, + "learn_from_others": HYL_CHOICE_LEARN_FROM_OTHERS, + "learn_on_demand": HYL_CHOICE_LEARN_ON_DEMAND, + "ai_enabled_support": HYL_CHOICE_AI_ENABLED_SUPPORT, + "stackable_credentials": HYL_CHOICE_STACKABLE_CREDENTIALS, +} diff --git a/cms/migrations/0060_add_hyl_choice_fields.py b/cms/migrations/0060_add_hyl_choice_fields.py new file mode 100644 index 0000000000..821bca54f4 --- /dev/null +++ b/cms/migrations/0060_add_hyl_choice_fields.py @@ -0,0 +1,132 @@ +# Generated by Django 5.1.15 on 2026-04-14 15:49 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("cms", "0059_alter_certificatepage_verifiable_credential_criteria"), + ] + + operations = [ + migrations.AddField( + model_name="coursepage", + name="hyl_choice_ai_enabled_support", + field=models.BooleanField( + blank=True, + default=False, + help_text="Get personalized help on assignments from AskTIM, powered by advanced AI.", + null=True, + ), + ), + migrations.AddField( + model_name="coursepage", + name="hyl_choice_learn_by_doing", + field=models.BooleanField( + blank=True, + default=False, + help_text="Practice core competencies through case studies, simulations, and hands-on tools.", + null=True, + ), + ), + migrations.AddField( + model_name="coursepage", + name="hyl_choice_learn_from_others", + field=models.BooleanField( + blank=True, + default=False, + help_text="Connect with an international community of professionals working on real-world projects.", + null=True, + ), + ), + migrations.AddField( + model_name="coursepage", + name="hyl_choice_learn_on_demand", + field=models.BooleanField( + blank=True, + default=False, + help_text="Access all course content online with complete flexibility to study at your own pace.", + null=True, + ), + ), + migrations.AddField( + model_name="coursepage", + name="hyl_choice_realworld_learning", + field=models.BooleanField( + blank=True, + default=False, + help_text="Learn from faculty experts who emphasize practical application over theory.", + null=True, + ), + ), + migrations.AddField( + model_name="coursepage", + name="hyl_choice_stackable_credentials", + field=models.BooleanField( + blank=True, + default=False, + help_text="Earn certificates at key milestones—module, course, and program—building a portfolio of expertise.", + null=True, + ), + ), + migrations.AddField( + model_name="programpage", + name="hyl_choice_ai_enabled_support", + field=models.BooleanField( + blank=True, + default=False, + help_text="Get personalized help on assignments from AskTIM, powered by advanced AI.", + null=True, + ), + ), + migrations.AddField( + model_name="programpage", + name="hyl_choice_learn_by_doing", + field=models.BooleanField( + blank=True, + default=False, + help_text="Practice core competencies through case studies, simulations, and hands-on tools.", + null=True, + ), + ), + migrations.AddField( + model_name="programpage", + name="hyl_choice_learn_from_others", + field=models.BooleanField( + blank=True, + default=False, + help_text="Connect with an international community of professionals working on real-world projects.", + null=True, + ), + ), + migrations.AddField( + model_name="programpage", + name="hyl_choice_learn_on_demand", + field=models.BooleanField( + blank=True, + default=False, + help_text="Access all course content online with complete flexibility to study at your own pace.", + null=True, + ), + ), + migrations.AddField( + model_name="programpage", + name="hyl_choice_realworld_learning", + field=models.BooleanField( + blank=True, + default=False, + help_text="Learn from faculty experts who emphasize practical application over theory.", + null=True, + ), + ), + migrations.AddField( + model_name="programpage", + name="hyl_choice_stackable_credentials", + field=models.BooleanField( + blank=True, + default=False, + help_text="Earn certificates at key milestones—module, course, and program—building a portfolio of expertise.", + null=True, + ), + ), + ] diff --git a/cms/models.py b/cms/models.py index f2d1c7d422..26304618bc 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, Textarea +from django.forms import CheckboxInput, ChoiceField, IntegerField, Textarea from django.http import Http404 from django.template.response import TemplateResponse from django.urls import reverse @@ -27,6 +27,7 @@ from wagtail.admin.panels import ( FieldPanel, InlinePanel, + MultiFieldPanel, PageChooserPanel, ) from wagtail.api import APIField @@ -57,6 +58,7 @@ from cms.constants import ( CERTIFICATE_INDEX_SLUG, COURSE_INDEX_SLUG, + HYL_CHOICES, INSTRUCTOR_INDEX_SLUG, PROGRAM_COLLECTION_INDEX_SLUG, PROGRAM_INDEX_SLUG, @@ -1159,6 +1161,46 @@ class Meta: help_text="*Required for Verifiable Credential generation. What you will learn from this course.", ) + # How You'll Learn choice fields - these toggle on or off components on the + # Learn product pages. + hyl_choice_realworld_learning = models.BooleanField( + help_text="Learn from faculty experts who emphasize practical application over theory.", + null=True, + blank=True, + default=False, + ) + hyl_choice_learn_by_doing = models.BooleanField( + help_text="Practice core competencies through case studies, simulations, and hands-on tools.", + null=True, + blank=True, + default=False, + ) + hyl_choice_learn_from_others = models.BooleanField( + help_text="Connect with an international community of professionals working on real-world projects.", + null=True, + blank=True, + default=False, + ) + hyl_choice_learn_on_demand = models.BooleanField( + help_text="Access all course content online with complete flexibility to study at your own pace.", + null=True, + blank=True, + default=False, + ) + hyl_choice_ai_enabled_support = models.BooleanField( + help_text="Get personalized help on assignments from AskTIM, powered by advanced AI.", + null=True, + blank=True, + default=False, + ) + hyl_choice_stackable_credentials = models.BooleanField( + help_text="Earn certificates at key milestones—module, course, and program—building a portfolio of expertise.", + null=True, + blank=True, + default=False, + ) + # end How You'll Learn choice fields + feature_image = models.ForeignKey( Image, null=True, @@ -1191,6 +1233,41 @@ class Meta: FieldPanel("faq_url"), FieldPanel("about"), FieldPanel("what_you_learn"), + MultiFieldPanel( + children=( + FieldPanel( + "hyl_choice_realworld_learning", + widget=CheckboxInput, + heading="Real-world Learning", + ), + FieldPanel( + "hyl_choice_learn_by_doing", + widget=CheckboxInput, + heading="Learn By Doing", + ), + FieldPanel( + "hyl_choice_learn_from_others", + widget=CheckboxInput, + heading="Learn From Others", + ), + FieldPanel( + "hyl_choice_learn_on_demand", + widget=CheckboxInput, + heading="Learn On Demand", + ), + FieldPanel( + "hyl_choice_ai_enabled_support", + widget=CheckboxInput, + heading="AI-Enabled Support", + ), + FieldPanel( + "hyl_choice_stackable_credentials", + widget=CheckboxInput, + heading="Stackable Credentials", + ), + ), + heading="How You'll Learn", + ), FieldPanel("feature_image"), FieldPanel("video_url"), FieldPanel("faculty_section_title"), @@ -1219,6 +1296,7 @@ class Meta: APIField("faculty_section_title"), APIField("faculty"), APIField("certificate_page", serializer=ProductChildPageSerializer()), + APIField("how_youll_learn"), ] subpage_types = ["FlexiblePricingRequestForm", "CertificatePage"] @@ -1279,6 +1357,16 @@ def product(self): """Returns the courseware object (Course, Program) associated with this page""" raise NotImplementedError + @property + def how_youll_learn(self): + """Returns the selected choices for the How You'll Learn section.""" + + return [ + {"key": key, **HYL_CHOICES[key]} + for key in HYL_CHOICES + if getattr(self, f"hyl_choice_{key}", False) + ] + def get_url_parts(self, request=None): """ Overrides base method for returning the parts of the URL for pages of this class diff --git a/cms/wagtail_api/schema/serializers.py b/cms/wagtail_api/schema/serializers.py index 21955d0d30..80710a8bed 100644 --- a/cms/wagtail_api/schema/serializers.py +++ b/cms/wagtail_api/schema/serializers.py @@ -86,6 +86,15 @@ class OverrideSerializer(serializers.Serializer): id = serializers.CharField() +class HowYoullLearnSerializer(serializers.Serializer): + """Serializer for the How You'll Learn generated property""" + + key = serializers.CharField() + icon = serializers.CharField() + title = serializers.CharField() + text = serializers.CharField() + + class PageMetaSerializer(serializers.Serializer): """ Serializer for page metadata used in various Wagtail pages. @@ -189,6 +198,7 @@ class Meta: "topic_list", "include_in_learn_catalog", "ingest_content_files_for_ai", + "how_youll_learn", ] # NOTE: We use this serializer for schema generation only, @@ -202,6 +212,7 @@ class Meta: certificate_page = CertificatePageSerializer(allow_null=True) course_details = CourseSerializer() topic_list = TopicSerializer(many=True) + how_youll_learn = HowYoullLearnSerializer(many=True) class CoursePageListSerializer(serializers.Serializer): @@ -246,6 +257,7 @@ class Meta: "faculty", "certificate_page", "program_details", + "how_youll_learn", ] # NOTE: We use this serializer for schema generation only, @@ -265,6 +277,7 @@ def get_description(self, instance): faculty = FacultySerializer(many=True) certificate_page = CertificatePageSerializer() program_details = ProgramSerializer() + how_youll_learn = HowYoullLearnSerializer(many=True) class ProgramPageListSerializer(serializers.Serializer): diff --git a/openapi/specs/v0.yaml b/openapi/specs/v0.yaml index 1ad896bec6..1ce27634ef 100644 --- a/openapi/specs/v0.yaml +++ b/openapi/specs/v0.yaml @@ -4192,6 +4192,10 @@ components: nullable: true description: If true, allow the AI chatbots to ingest the course's content files. + how_youll_learn: + type: array + items: + $ref: '#/components/schemas/HowYoullLearn' required: - about - certificate_page @@ -4202,6 +4206,7 @@ components: - faculty_section_title - faq_url - feature_image + - how_youll_learn - id - include_in_learn_catalog - ingest_content_files_for_ai @@ -5441,6 +5446,23 @@ components: - Elementary/primary school - No formal education - Other education + HowYoullLearn: + type: object + description: Serializer for the How You'll Learn generated property + properties: + key: + type: string + icon: + type: string + title: + type: string + text: + type: string + required: + - icon + - key + - text + - title IntegrationTypeEnum: enum: - sso @@ -6989,6 +7011,10 @@ components: $ref: '#/components/schemas/CertificatePage' program_details: $ref: '#/components/schemas/V2Program' + how_youll_learn: + type: array + items: + $ref: '#/components/schemas/HowYoullLearn' required: - about - certificate_page @@ -6998,6 +7024,7 @@ components: - faculty_section_title - faq_url - feature_image + - how_youll_learn - id - length - max_price diff --git a/openapi/specs/v1.yaml b/openapi/specs/v1.yaml index 3b9fd65e9a..8e7169e9a7 100644 --- a/openapi/specs/v1.yaml +++ b/openapi/specs/v1.yaml @@ -4192,6 +4192,10 @@ components: nullable: true description: If true, allow the AI chatbots to ingest the course's content files. + how_youll_learn: + type: array + items: + $ref: '#/components/schemas/HowYoullLearn' required: - about - certificate_page @@ -4202,6 +4206,7 @@ components: - faculty_section_title - faq_url - feature_image + - how_youll_learn - id - include_in_learn_catalog - ingest_content_files_for_ai @@ -5441,6 +5446,23 @@ components: - Elementary/primary school - No formal education - Other education + HowYoullLearn: + type: object + description: Serializer for the How You'll Learn generated property + properties: + key: + type: string + icon: + type: string + title: + type: string + text: + type: string + required: + - icon + - key + - text + - title IntegrationTypeEnum: enum: - sso @@ -6989,6 +7011,10 @@ components: $ref: '#/components/schemas/CertificatePage' program_details: $ref: '#/components/schemas/V2Program' + how_youll_learn: + type: array + items: + $ref: '#/components/schemas/HowYoullLearn' required: - about - certificate_page @@ -6998,6 +7024,7 @@ components: - faculty_section_title - faq_url - feature_image + - how_youll_learn - id - length - max_price diff --git a/openapi/specs/v2.yaml b/openapi/specs/v2.yaml index 980098ce31..2c0bcae9fd 100644 --- a/openapi/specs/v2.yaml +++ b/openapi/specs/v2.yaml @@ -4192,6 +4192,10 @@ components: nullable: true description: If true, allow the AI chatbots to ingest the course's content files. + how_youll_learn: + type: array + items: + $ref: '#/components/schemas/HowYoullLearn' required: - about - certificate_page @@ -4202,6 +4206,7 @@ components: - faculty_section_title - faq_url - feature_image + - how_youll_learn - id - include_in_learn_catalog - ingest_content_files_for_ai @@ -5441,6 +5446,23 @@ components: - Elementary/primary school - No formal education - Other education + HowYoullLearn: + type: object + description: Serializer for the How You'll Learn generated property + properties: + key: + type: string + icon: + type: string + title: + type: string + text: + type: string + required: + - icon + - key + - text + - title IntegrationTypeEnum: enum: - sso @@ -6989,6 +7011,10 @@ components: $ref: '#/components/schemas/CertificatePage' program_details: $ref: '#/components/schemas/V2Program' + how_youll_learn: + type: array + items: + $ref: '#/components/schemas/HowYoullLearn' required: - about - certificate_page @@ -6998,6 +7024,7 @@ components: - faculty_section_title - faq_url - feature_image + - how_youll_learn - id - length - max_price From 8696ce31e6890c604205030c02013fa6ac5d7f21 Mon Sep 17 00:00:00 2001 From: Doof Date: Thu, 16 Apr 2026 17:15:46 +0000 Subject: [PATCH 5/5] Release 1.146.5 --- RELEASE.rst | 8 ++++++++ main/settings.py | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/RELEASE.rst b/RELEASE.rst index ee0149c482..b359832f12 100644 --- a/RELEASE.rst +++ b/RELEASE.rst @@ -1,6 +1,14 @@ Release Notes ============= +Version 1.146.5 +--------------- + +- Add fields for configuring the How You'll Learn cards on product pages (#3487) +- feat: add webhook endpoint for Open edX course enrollment (#3372) +- Make enrollment_mode field non-nullable (#3494) +- [pre-commit.ci] pre-commit autoupdate (#3486) + Version 1.146.4 (Released April 16, 2026) --------------- diff --git a/main/settings.py b/main/settings.py index bbe07c8b2f..b8e0dd9689 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.4" +VERSION = "1.146.5" log = logging.getLogger()