From fe570fd97ed739e6c412cd26cfa2175fdf782d83 Mon Sep 17 00:00:00 2001 From: James Kachel Date: Mon, 9 Mar 2026 17:01:45 -0500 Subject: [PATCH 1/8] Add search and filters to enrollment code attachment Django admin (#3362) --- b2b/admin.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/b2b/admin.py b/b2b/admin.py index d340d12c6d..0bbd492cfc 100644 --- a/b2b/admin.py +++ b/b2b/admin.py @@ -39,9 +39,29 @@ class DiscountContractAttachmentRedemptionAdmin(ReadOnlyModelAdmin): """Admin for discount attachments.""" list_display = ["user", "contract", "discount", "created_on"] + list_filter = [ + ( + "user", + admin.RelatedOnlyFieldListFilter, + ), + ( + "contract", + admin.RelatedOnlyFieldListFilter, + ), + ( + "user__user_organizations__organization", + admin.RelatedOnlyFieldListFilter, + ), + ] date_hierarchy = "created_on" fields = ["user", "contract", "discount", "created_on"] readonly_fields = ["user", "contract", "discount", "created_on"] + search_fields = [ + "user__email", + "user__global_id", + "contract__slug", + "discount__discount_code", + ] class ContractPageProgramInline(admin.TabularInline): From 346e593ab8a025e5886074b0397207d3a396cabe Mon Sep 17 00:00:00 2001 From: James Kachel Date: Mon, 9 Mar 2026 17:02:09 -0500 Subject: [PATCH 2/8] Fix sometimes flaky program filter test bug (#3364) --- b2b/factories.py | 2 +- conftest.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/b2b/factories.py b/b2b/factories.py index ce2268c37f..e963720c70 100644 --- a/b2b/factories.py +++ b/b2b/factories.py @@ -35,7 +35,7 @@ class OrganizationPageFactory(wagtail_factories.PageFactory): """OrganizationPage factory class""" name = LazyAttribute(lambda _: FAKE.unique.company()) - org_key = LazyAttribute(lambda _: FAKE.unique.text(max_nb_chars=5)) + org_key = LazyAttribute(lambda _: "".join(FAKE.unique.random_letters(length=5))) org_key_prefix = UAI_COURSEWARE_ID_PREFIX description = LazyAttribute(lambda _: FAKE.unique.text()) logo = None diff --git a/conftest.py b/conftest.py index 313b482af7..89d9e35c0e 100644 --- a/conftest.py +++ b/conftest.py @@ -84,4 +84,5 @@ def pytest_configure(config): @pytest.fixture(autouse=True, scope="module") def fake() -> Faker: """Fixture to provide a Faker instance""" + return Faker() From d5325b187363e867bd032c0a89676cdc806f914c Mon Sep 17 00:00:00 2001 From: James Kachel Date: Mon, 9 Mar 2026 17:02:29 -0500 Subject: [PATCH 3/8] Don't attempt to add users to Keycloak orgs if there's no org to add to (#3359) --- b2b/api.py | 3 +++ b2b/api_test.py | 8 +++++--- b2b/models.py | 3 +++ b2b/models_test.py | 26 ++++++++++++++++++++++++++ 4 files changed, 37 insertions(+), 3 deletions(-) diff --git a/b2b/api.py b/b2b/api.py index cf913875c5..93d83aa237 100644 --- a/b2b/api.py +++ b/b2b/api.py @@ -1273,6 +1273,9 @@ def add_user_org_membership(org, user): - bool: True if the user was added, False otherwise. """ + if not org.sso_organization_id: + return False + org_model = get_keycloak_model(OrganizationRepresentation, "organizations") kc_org = org_model.get(org.sso_organization_id) diff --git a/b2b/api_test.py b/b2b/api_test.py index 89530f2b30..50fa15fd35 100644 --- a/b2b/api_test.py +++ b/b2b/api_test.py @@ -2,6 +2,7 @@ from datetime import timedelta from decimal import Decimal +from uuid import uuid4 from zoneinfo import ZoneInfo import faker @@ -1000,15 +1001,16 @@ def test_b2b_contract_removal_keeps_enrollments(mocked_b2b_org_attach): assert courserun.enrollments.filter(user=user).count() == 1 -def test_b2b_org_attach_calls_keycloak(mocked_b2b_org_attach): +@pytest.mark.parametrize("has_id", [None, uuid4()]) +def test_b2b_org_attach_calls_keycloak(mocked_b2b_org_attach, has_id): """Test that attaching a user to an org calls Keycloak successfully.""" - org = OrganizationPageFactory.create() + org = OrganizationPageFactory.create(sso_organization_id=has_id) user = UserFactory.create() process_add_org_membership(user, org) - mocked_b2b_org_attach.assert_called() + mocked_b2b_org_attach.assert_called() if has_id else mocked_b2b_org_attach.assert_not_called() @pytest.mark.parametrize( diff --git a/b2b/models.py b/b2b/models.py index 6d0a1674f1..5f63905c71 100644 --- a/b2b/models.py +++ b/b2b/models.py @@ -143,6 +143,9 @@ def attach_user(self, user): from b2b.api import add_user_org_membership # noqa: PLC0415 + if not self.sso_organization_id: + return False + try: return add_user_org_membership(self, user) except HTTPError: diff --git a/b2b/models_test.py b/b2b/models_test.py index 944dcdfe90..d538d5d997 100644 --- a/b2b/models_test.py +++ b/b2b/models_test.py @@ -1,5 +1,7 @@ """Tests for models.""" +from uuid import uuid4 + import faker import pytest @@ -222,3 +224,27 @@ def test_get_unused_discounts(user): .filter(discount_code=code_to_use.discount_code) .exists() ) + + +@pytest.mark.parametrize( + "has_keycloak_id", + [ + True, + False, + ], +) +def test_attach_user_no_sso_id(mocker, has_keycloak_id): + """Test that attach_user bails out if there's no Keycloak ID""" + + patched_add_membership = mocker.patch( + "b2b.api.add_user_org_membership", return_value=True + ) + + org = OrganizationPageFactory.create( + sso_organization_id=uuid4() if has_keycloak_id else None + ) + user = UserFactory.create() + + result = org.attach_user(user) + assert result == has_keycloak_id + assert patched_add_membership.called == has_keycloak_id From 8cd067b6f2bca38fc8d248e4ec8e15cb5620f5b1 Mon Sep 17 00:00:00 2001 From: James Kachel Date: Tue, 10 Mar 2026 07:19:22 -0500 Subject: [PATCH 4/8] Check for a null start date; emit nothing in that case (#3368) --- frontend/public/src/lib/util.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/frontend/public/src/lib/util.js b/frontend/public/src/lib/util.js index 9c98c52ac9..d1973d6bb7 100644 --- a/frontend/public/src/lib/util.js +++ b/frontend/public/src/lib/util.js @@ -355,6 +355,10 @@ export const getStartDateText = ( courseware.courseruns.sort(compareCourseRunStartDates)[0] : courseware + if (!courseRun.start_date) { + return "" + } + if (moment(courseRun.start_date).isAfter(moment())) { return `Starts: ${formatPrettyDate(parseDateString(courseRun.start_date))}` } else { From 861e041686bc923db0791a81bc657ccfd51b6aa3 Mon Sep 17 00:00:00 2001 From: Chris Chudzicki Date: Tue, 10 Mar 2026 11:14:06 -0400 Subject: [PATCH 5/8] update olposthog (#3370) --- main/settings.py | 31 +------------------------------ pyproject.toml | 2 +- uv.lock | 18 +++++++++--------- 3 files changed, 11 insertions(+), 40 deletions(-) diff --git a/main/settings.py b/main/settings.py index 21a1c48e43..d7888f1679 100644 --- a/main/settings.py +++ b/main/settings.py @@ -1123,6 +1123,7 @@ import_settings_modules( "mitol.authentication.settings.djoser_settings", "mitol.payment_gateway.settings.cybersource", + "mitol.olposthog.settings.olposthog", ) # mitol-django-common @@ -1350,36 +1351,6 @@ description="Number of milliseconds to wait between consecutive Hubspot calls", ) -# PostHog related settings -POSTHOG_PROJECT_API_KEY = get_string( - name="POSTHOG_PROJECT_API_KEY", - default="", - description="API token to communicate with PostHog", -) - -POSTHOG_API_HOST = get_string( - name="POSTHOG_API_HOST", - default="", - description="API host for PostHog", -) -POSTHOG_FEATURE_FLAG_REQUEST_TIMEOUT_MS = get_int( - name="POSTHOG_FEATURE_FLAG_REQUEST_TIMEOUT_MS", - default=3000, - description="Timeout(MS) for PostHog feature flag requests.", -) - -POSTHOG_MAX_RETRIES = get_int( - name="POSTHOG_MAX_RETRIES", - default=3, - description="Number of times that requests to PostHog should be retried after failing.", -) - -POSTHOG_ENABLED = get_bool( - name="POSTHOG_ENABLED", - default=False, - description="Whether PostHog is enabled", -) - # HomePage Hubspot Form Settings HUBSPOT_HOME_PAGE_FORM_GUID = get_string( name="HUBSPOT_HOME_PAGE_FORM_GUID", diff --git a/pyproject.toml b/pyproject.toml index 3eaa67b353..2d134b2f78 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -81,7 +81,7 @@ dependencies = [ "authlib>=1.6.3,<2", "trino>=0.336.0,<0.337", "granian>=2.5.4,<3", - "mitol-django-olposthog>=2025.8.1,<2026", + "mitol-django-olposthog>=2026.3.6,<2027", "django-removals>=1.1.6,<2", "django-prefetch>=1.2.3,<2", ] diff --git a/uv.lock b/uv.lock index 428f17bd90..6619e45dd0 100644 --- a/uv.lock +++ b/uv.lock @@ -1,6 +1,6 @@ version = 1 -revision = 3 -requires-python = "==3.11.*" +revision = 2 +requires-python = ">=3.11.0, <3.12" [[package]] name = "amqp" @@ -1798,7 +1798,7 @@ wheels = [ [[package]] name = "mitol-django-olposthog" -version = "2025.8.1" +version = "2026.3.6" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "django" }, @@ -1806,9 +1806,9 @@ dependencies = [ { name = "mitol-django-common" }, { name = "posthog" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/bb/9f/275cd29a76432be4351eaa2e1fc51ea89dc8615bcd0edbf2b9a9675585a3/mitol_django_olposthog-2025.8.1.tar.gz", hash = "sha256:98dbb68b94c836626f0afce5b23c1c594e96d1ebf62bcd756d676cf8f89bf492", size = 4240, upload-time = "2025-08-01T11:15:53.271Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f4/5f/3e9a4ea3b699d803858440b10c0cff2981ae92c1975ca56ec462733740e0/mitol_django_olposthog-2026.3.6.tar.gz", hash = "sha256:6c03275b8ffa4a57fc533ddc9619262db4ce79a1970b3bd6dcb639c45a7494fc", size = 5624, upload-time = "2026-03-06T15:17:13.498Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/33/14/5cd6744f5c59ae65e4ca9e65bd83265fd6294fe1f4bca4f3109a088f911d/mitol_django_olposthog-2025.8.1-py3-none-any.whl", hash = "sha256:6bdd9926eacdc34770cc0841d268a99ca45a00ff2068209caf9df364c1fff6d1", size = 6817, upload-time = "2025-08-01T11:15:52.554Z" }, + { url = "https://files.pythonhosted.org/packages/ae/28/bb08ac8a36b5e4258866f04a9781461d3cc62d2ebda477b83c5b565ced21/mitol_django_olposthog-2026.3.6-py3-none-any.whl", hash = "sha256:9aaf4bcd396eb803a2e4130ad5892bed03ba50f56b211657a08da242cb15ba21", size = 9065, upload-time = "2026-03-06T15:17:12.759Z" }, ] [[package]] @@ -2019,7 +2019,7 @@ 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-olposthog", specifier = ">=2025.8.1,<2026" }, + { 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" }, @@ -2528,7 +2528,7 @@ wheels = [ [[package]] name = "posthog" -version = "6.9.3" +version = "7.9.8" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "backoff" }, @@ -2538,9 +2538,9 @@ dependencies = [ { name = "six" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b3/03/ed31e77f260971ed633c13815107b08edf999c7d1ec769d6313765ec89cb/posthog-6.9.3.tar.gz", hash = "sha256:7d201774ea9eba156f1de46d34313e30b2384d523900fe8e425accc92486cc34", size = 126554, upload-time = "2025-11-11T17:56:58.191Z" } +sdist = { url = "https://files.pythonhosted.org/packages/63/f5/490fbe0cd357bf5efaa026200d2a29aaa5e39cd8272cfe0e2d449f46f2db/posthog-7.9.8.tar.gz", hash = "sha256:52b1fa5f3d3faf2ee2fb7f5eb375332905887f7c1e386ef45103448413bd3e57", size = 176688, upload-time = "2026-03-09T14:34:07.822Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a7/4b/e4759803793843ce2258ddef3b3a744bdf0318c77c3ac10560683a2eee60/posthog-6.9.3-py3-none-any.whl", hash = "sha256:c71e9cb7ac4ef13eb604f04c3161edd10b1d08a32499edd54437ba5eab591c58", size = 144740, upload-time = "2025-11-11T17:56:56.986Z" }, + { url = "https://files.pythonhosted.org/packages/0f/aa/8b3de1650e0c39223c7f9b7c0f4961f7d39bfa690fa800a9521565381ecb/posthog-7.9.8-py3-none-any.whl", hash = "sha256:2735bcc3232e22c88034454e820c1739f4b29e606d55f31e56b52202650e4330", size = 202361, upload-time = "2026-03-09T14:34:06.031Z" }, ] [[package]] From 6eb8d3b9e2e0e960a637d1766ff99dafd3423588 Mon Sep 17 00:00:00 2001 From: Chris Chudzicki Date: Tue, 10 Mar 2026 11:22:38 -0400 Subject: [PATCH 6/8] Add server-side redirect for /dashboard to learn.mit.edu (#3356) Co-authored-by: Claude Sonnet 4.6 --- .../src/containers/pages/DashboardPage.js | 3 +- .../containers/pages/DashboardPage_test.js | 29 ++++++++- main/features.py | 2 + main/urls.py | 3 +- main/views.py | 24 +++++++ main/views_test.py | 62 +++++++++++++++++++ 6 files changed, 119 insertions(+), 4 deletions(-) diff --git a/frontend/public/src/containers/pages/DashboardPage.js b/frontend/public/src/containers/pages/DashboardPage.js index 9e8e1c9721..d760330b94 100644 --- a/frontend/public/src/containers/pages/DashboardPage.js +++ b/frontend/public/src/containers/pages/DashboardPage.js @@ -104,9 +104,10 @@ export class DashboardPage extends React.Component< ) if (flagEnabled) { - window.location.href = + const baseUrl = SETTINGS.mit_learn_dashboard_url || "https://learn.mit.edu/dashboard" + window.location.href = baseUrl + window.location.search return } } diff --git a/frontend/public/src/containers/pages/DashboardPage_test.js b/frontend/public/src/containers/pages/DashboardPage_test.js index 96cda0d373..03ca1b7823 100644 --- a/frontend/public/src/containers/pages/DashboardPage_test.js +++ b/frontend/public/src/containers/pages/DashboardPage_test.js @@ -89,8 +89,8 @@ describe("DashboardPage", () => { let mockLocation, posthogIdentifyStub, checkFeatureFlagStub, clock beforeEach(() => { - // Mock window.location.href - mockLocation = { href: "" } + // Mock window.location.href and search + mockLocation = { href: "", search: "" } sandbox.stub(window, "location").value(mockLocation) // Mock PostHog methods @@ -156,6 +156,31 @@ describe("DashboardPage", () => { assert.equal(mockLocation.href, "https://learn.mit.edu/dashboard") }) + it("preserves query parameters in the redirect URL", async () => { + const mockUser = makeUser() + mockUser.global_id = "test-guid-123" + + mockLocation.search = "?a=1&b=2" + + checkFeatureFlagStub + .withArgs("redirect-to-learn-dashboard", "test-guid-123") + .returns(true) + + await renderPage( + { + entities: { + enrollments: userEnrollments, + currentUser: mockUser + } + }, + { currentUser: mockUser } + ) + + clock.tick(500) + + assert.equal(mockLocation.href, "https://learn.mit.edu/dashboard?a=1&b=2") + }) + it("does not redirect when feature flag is disabled", async () => { const mockUser = makeUser() mockUser.global_id = "test-guid-123" diff --git a/main/features.py b/main/features.py index f95ce99bf4..63396bfce7 100644 --- a/main/features.py +++ b/main/features.py @@ -9,3 +9,5 @@ ENABLE_VERIFIABLE_CREDENTIALS_PROVISIONING = ( "mitxonline-verifiable-credentials-provisioning" ) + +REDIRECT_LEARN_DASHBOARD = "redirect-to-learn-dashboard" diff --git a/main/urls.py b/main/urls.py index 0ee1a62e12..38fcbc7ce7 100644 --- a/main/urls.py +++ b/main/urls.py @@ -28,6 +28,7 @@ from cms.wagtail_api.urls import api_router as wagtail_api_router from main.views import ( cms_signin_redirect_to_site_signin, + dashboard, index, refine, staff_dashboard_signin_redirect_to_site_signin, @@ -61,7 +62,7 @@ path("", include("mitol.google_sheets.urls")), path("", include("b2b.urls")), re_path(r"", include("mitol.scim.urls")), - re_path(r"^dashboard/", index, name="user-dashboard"), + re_path(r"^dashboard/", dashboard, name="user-dashboard"), # Staff dashboard authentication redirect re_path( r"^staff-dashboard/$", diff --git a/main/views.py b/main/views.py index c4e233422a..ee9f8201f8 100644 --- a/main/views.py +++ b/main/views.py @@ -6,14 +6,18 @@ from django.contrib.auth.views import redirect_to_login from django.http import ( HttpResponseNotFound, + HttpResponseRedirect, HttpResponseServerError, ) from django.shortcuts import render from django.template.loader import render_to_string from django.urls import reverse from django.views.decorators.cache import never_cache +from mitol.olposthog.features import is_enabled from rest_framework.pagination import LimitOffsetPagination +from main import features + def get_base_context(request): # noqa: ARG001 """ @@ -38,6 +42,26 @@ def index(request, **kwargs): return render(request, "index.html", context=context) +@never_cache +def dashboard(request, **kwargs): + """ + Dashboard view - redirects to the new learn frontend if the feature flag + is enabled for this user, otherwise serves the legacy React app. + """ + if request.user.is_authenticated: + global_id = request.user.global_id + if global_id and is_enabled( + features.REDIRECT_LEARN_DASHBOARD, + default=False, + opt_unique_id=global_id, + ): + redirect_url = settings.MIT_LEARN_DASHBOARD_URL + if qs := request.META.get("QUERY_STRING"): + redirect_url = f"{redirect_url}?{qs}" + return HttpResponseRedirect(redirect_url) + return index(request, **kwargs) + + @never_cache def refine(request, **kwargs): # noqa: ARG001 """ diff --git a/main/views_test.py b/main/views_test.py index d0a223e0b6..054b4361f3 100644 --- a/main/views_test.py +++ b/main/views_test.py @@ -3,8 +3,11 @@ """ import pytest +from django.test import Client from django.urls import reverse +from users.factories import UserFactory + pytestmark = [ pytest.mark.django_db, ] @@ -32,6 +35,65 @@ def test_never_cache_react_views(staff_client, url_name): ) +@pytest.mark.parametrize( + "flag_enabled", + [True, False], +) +def test_dashboard_redirect(settings, mocker, flag_enabled): + """Authenticated users with global_id are redirected when the flag is enabled.""" + user = UserFactory.create(global_id="test-global-id") + client = Client() + client.force_login(user) + + mocker.patch("main.views.is_enabled", return_value=flag_enabled) + + response = client.get("/dashboard/", follow=False) + + if flag_enabled: + assert response.status_code == 302 + assert response.url == settings.MIT_LEARN_DASHBOARD_URL + else: + assert response.status_code == 200 + + +def test_dashboard_redirect_preserves_query_params(settings, mocker): + """Query parameters are forwarded to the learn dashboard redirect URL.""" + user = UserFactory.create(global_id="test-global-id") + client = Client() + client.force_login(user) + + mocker.patch("main.views.is_enabled", return_value=True) + + response = client.get("/dashboard/?a=1&b=2", follow=False) + + assert response.status_code == 302 + assert response.url == f"{settings.MIT_LEARN_DASHBOARD_URL}?a=1&b=2" + + +def test_dashboard_no_redirect_without_global_id(mocker): + """Authenticated users without a global_id are never redirected.""" + user = UserFactory.create(global_id=None) + client = Client() + client.force_login(user) + + mock_is_enabled = mocker.patch("main.views.is_enabled") + + response = client.get("/dashboard/") + + mock_is_enabled.assert_not_called() + assert response.status_code == 200 + + +def test_dashboard_no_redirect_for_anonymous(client, mocker): + """Unauthenticated users are never redirected and never check the flag.""" + mock_is_enabled = mocker.patch("main.views.is_enabled") + + response = client.get("/dashboard/") + + mock_is_enabled.assert_not_called() + assert response.status_code == 200 + + @pytest.mark.parametrize( ("path", "expect_noindex"), [ From aeb51f5b59119d7a552fb3d7fa905c94955b1514 Mon Sep 17 00:00:00 2001 From: cp-at-mit Date: Tue, 10 Mar 2026 11:30:36 -0400 Subject: [PATCH 7/8] 10405 add display mode field for mitxonline programs returned in the api (#3358) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Chris Chudzicki --- courses/admin.py | 11 ++++- courses/constants.py | 3 ++ courses/forms.py | 1 + .../migrations/0087_program_display_mode.py | 23 +++++++++ .../0088_alter_program_display_mode.py | 23 +++++++++ courses/models.py | 8 ++++ courses/serializers/v1/base.py | 1 + courses/serializers/v1/base_test.py | 1 + courses/serializers/v2/programs.py | 1 + courses/serializers/v2/programs_test.py | 1 + courses/serializers/v3/programs.py | 1 + courses/serializers/v3/programs_test.py | 1 + courses/views/v2/views_test.py | 1 + openapi/specs/v0.yaml | 47 +++++++++++++++++++ openapi/specs/v1.yaml | 47 +++++++++++++++++++ openapi/specs/v2.yaml | 47 +++++++++++++++++++ 16 files changed, 215 insertions(+), 2 deletions(-) create mode 100644 courses/migrations/0087_program_display_mode.py create mode 100644 courses/migrations/0088_alter_program_display_mode.py diff --git a/courses/admin.py b/courses/admin.py index 63b7a2e106..40a901bdd1 100644 --- a/courses/admin.py +++ b/courses/admin.py @@ -57,8 +57,15 @@ class ProgramAdmin(admin.ModelAdmin): model = Program form = ProgramAdminForm search_fields = ["title", "readable_id", "program_type"] - list_display = ("id", "title", "live", "readable_id", "program_type") - list_filter = ["live", "program_type", "departments"] + list_display = ( + "id", + "title", + "live", + "readable_id", + "program_type", + "display_mode", + ) + list_filter = ["live", "program_type", "display_mode", "departments"] inlines = [ProgramContractPageInline] diff --git a/courses/constants.py b/courses/constants.py index 1e7e72ce4e..81506a519e 100644 --- a/courses/constants.py +++ b/courses/constants.py @@ -9,6 +9,9 @@ VALID_PRODUCT_TYPES = {CONTENT_TYPE_MODEL_COURSERUN, CONTENT_TYPE_MODEL_PROGRAM} VALID_PRODUCT_TYPE_CHOICES = list(zip(VALID_PRODUCT_TYPES, VALID_PRODUCT_TYPES)) +# Program display modes +PROGRAM_DISPLAY_MODE_CHOICES = [("course", "course")] + PROGRAM_TEXT_ID_PREFIX = "program-" ENROLLABLE_ITEM_ID_SEPARATOR = "+" TEXT_ID_RUN_TAG_PATTERN = rf"\{ENROLLABLE_ITEM_ID_SEPARATOR}(?PR\d+)$" diff --git a/courses/forms.py b/courses/forms.py index e96ba66591..74583d9eeb 100644 --- a/courses/forms.py +++ b/courses/forms.py @@ -434,6 +434,7 @@ class Meta: "enrollment_start", "enrollment_end", "b2b_only", + "display_mode", "enrollment_modes", ] diff --git a/courses/migrations/0087_program_display_mode.py b/courses/migrations/0087_program_display_mode.py new file mode 100644 index 0000000000..d8b1785d04 --- /dev/null +++ b/courses/migrations/0087_program_display_mode.py @@ -0,0 +1,23 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("courses", "0086_backfill_enrollment_modes"), + ] + + operations = [ + migrations.AddField( + model_name="program", + name="display_mode", + field=models.CharField( + blank=True, + null=True, + max_length=32, + choices=[("course", "course")], + help_text=( + "Provide a hint to other services (frontends, aggregators) about how this program should treated." + ), + ), + ), + ] diff --git a/courses/migrations/0088_alter_program_display_mode.py b/courses/migrations/0088_alter_program_display_mode.py new file mode 100644 index 0000000000..9951164886 --- /dev/null +++ b/courses/migrations/0088_alter_program_display_mode.py @@ -0,0 +1,23 @@ +# Generated by Django 5.1.15 on 2026-03-10 15:11 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("courses", "0087_program_display_mode"), + ] + + operations = [ + migrations.AlterField( + model_name="program", + name="display_mode", + field=models.CharField( + blank=True, + choices=[("course", "course")], + help_text="Set to 'course' to treat this program as a course in APIs.", + max_length=32, + null=True, + ), + ), + ] diff --git a/courses/models.py b/courses/models.py index 30bd8c821e..1e373f3c18 100644 --- a/courses/models.py +++ b/courses/models.py @@ -33,6 +33,7 @@ AVAILABILITY_CHOICES, ENROLL_CHANGE_STATUS_CHOICES, ENROLLABLE_ITEM_ID_SEPARATOR, + PROGRAM_DISPLAY_MODE_CHOICES, SYNCED_COURSE_RUN_FIELD_MSG, ) from main.models import AuditableModel, AuditModel, ValidateOnSaveMixin @@ -305,6 +306,13 @@ class Meta: enrollment_modes = models.ManyToManyField( EnrollmentMode, blank=True, related_name="+" ) + display_mode = models.CharField( # noqa: DJ001 + max_length=32, + choices=PROGRAM_DISPLAY_MODE_CHOICES, + blank=True, + null=True, + help_text="Set to 'course' to treat this program as a course in APIs.", + ) start_date = models.DateTimeField(null=True, blank=True, db_index=True) end_date = models.DateTimeField(null=True, blank=True, db_index=True) b2b_only = models.BooleanField( diff --git a/courses/serializers/v1/base.py b/courses/serializers/v1/base.py index dc34012c11..12e73d9adc 100644 --- a/courses/serializers/v1/base.py +++ b/courses/serializers/v1/base.py @@ -122,6 +122,7 @@ class Meta: "readable_id", "id", "type", + "display_mode", ] diff --git a/courses/serializers/v1/base_test.py b/courses/serializers/v1/base_test.py index 771e326c39..cde4820d62 100644 --- a/courses/serializers/v1/base_test.py +++ b/courses/serializers/v1/base_test.py @@ -15,6 +15,7 @@ def test_base_program_serializer(): "readable_id": program.readable_id, "id": program.id, "type": "program", + "display_mode": None, } diff --git a/courses/serializers/v2/programs.py b/courses/serializers/v2/programs.py index 73e8e0d680..bde1d3e3e9 100644 --- a/courses/serializers/v2/programs.py +++ b/courses/serializers/v2/programs.py @@ -532,6 +532,7 @@ class Meta: "certificate_type", "departments", "live", + "display_mode", "topics", "availability", "start_date", diff --git a/courses/serializers/v2/programs_test.py b/courses/serializers/v2/programs_test.py index fb9792a1ce..ade2e24afc 100644 --- a/courses/serializers/v2/programs_test.py +++ b/courses/serializers/v2/programs_test.py @@ -153,6 +153,7 @@ def test_serialize_program( "min_price": program_with_empty_requirements.page.min_price, "max_price": program_with_empty_requirements.page.max_price, "enrollment_modes": [], + "display_mode": None, }, ) diff --git a/courses/serializers/v3/programs.py b/courses/serializers/v3/programs.py index b757bf43ca..ef3c62b659 100644 --- a/courses/serializers/v3/programs.py +++ b/courses/serializers/v3/programs.py @@ -22,6 +22,7 @@ class Meta: "id", "program_type", "live", + "display_mode", ] diff --git a/courses/serializers/v3/programs_test.py b/courses/serializers/v3/programs_test.py index 4be2e5a773..2e7e5f3f74 100644 --- a/courses/serializers/v3/programs_test.py +++ b/courses/serializers/v3/programs_test.py @@ -23,6 +23,7 @@ def test_serialize_program_enrollment(user, with_certificate): data, { "program": { + "display_mode": None, "id": enrollment.program.id, "readable_id": enrollment.program.readable_id, "title": enrollment.program.title, diff --git a/courses/views/v2/views_test.py b/courses/views/v2/views_test.py index 437e69c84e..f6c52de00e 100644 --- a/courses/views/v2/views_test.py +++ b/courses/views/v2/views_test.py @@ -1467,6 +1467,7 @@ def _get_page_prop(program_enrollment, prop, default=None): ) != "", "topics": [], + "display_mode": None, "start_date": ANY_STR, "end_date": None, "enrollment_end": None, diff --git a/openapi/specs/v0.yaml b/openapi/specs/v0.yaml index 41067aad7c..1e470f065e 100644 --- a/openapi/specs/v0.yaml +++ b/openapi/specs/v0.yaml @@ -3237,6 +3237,16 @@ components: type: type: string readOnly: true + display_mode: + nullable: true + description: |- + Set to 'course' to treat this program as a course in APIs. + + * `course` - course + oneOf: + - $ref: '#/components/schemas/DisplayModeEnum' + - $ref: '#/components/schemas/BlankEnum' + - $ref: '#/components/schemas/NullEnum' required: - id - readable_id @@ -4565,6 +4575,13 @@ components: - percent-off - dollars-off - fixed-price + DisplayModeEnum: + enum: + - course + type: string + description: '* `course` - course' + x-enum-descriptions: + - course EnrollmentMode: type: object description: Enrollment mode serializer. @@ -7839,6 +7856,16 @@ components: readOnly: true live: type: boolean + display_mode: + nullable: true + description: |- + Set to 'course' to treat this program as a course in APIs. + + * `course` - course + oneOf: + - $ref: '#/components/schemas/DisplayModeEnum' + - $ref: '#/components/schemas/BlankEnum' + - $ref: '#/components/schemas/NullEnum' topics: type: array items: @@ -8114,6 +8141,16 @@ components: readOnly: true live: type: boolean + display_mode: + nullable: true + description: |- + Set to 'course' to treat this program as a course in APIs. + + * `course` - course + oneOf: + - $ref: '#/components/schemas/DisplayModeEnum' + - $ref: '#/components/schemas/BlankEnum' + - $ref: '#/components/schemas/NullEnum' topics: type: array items: @@ -8343,6 +8380,16 @@ components: maxLength: 255 live: type: boolean + display_mode: + nullable: true + description: |- + Set to 'course' to treat this program as a course in APIs. + + * `course` - course + oneOf: + - $ref: '#/components/schemas/DisplayModeEnum' + - $ref: '#/components/schemas/BlankEnum' + - $ref: '#/components/schemas/NullEnum' required: - id - readable_id diff --git a/openapi/specs/v1.yaml b/openapi/specs/v1.yaml index 9bcd12e4d1..2f6481c87f 100644 --- a/openapi/specs/v1.yaml +++ b/openapi/specs/v1.yaml @@ -3237,6 +3237,16 @@ components: type: type: string readOnly: true + display_mode: + nullable: true + description: |- + Set to 'course' to treat this program as a course in APIs. + + * `course` - course + oneOf: + - $ref: '#/components/schemas/DisplayModeEnum' + - $ref: '#/components/schemas/BlankEnum' + - $ref: '#/components/schemas/NullEnum' required: - id - readable_id @@ -4565,6 +4575,13 @@ components: - percent-off - dollars-off - fixed-price + DisplayModeEnum: + enum: + - course + type: string + description: '* `course` - course' + x-enum-descriptions: + - course EnrollmentMode: type: object description: Enrollment mode serializer. @@ -7839,6 +7856,16 @@ components: readOnly: true live: type: boolean + display_mode: + nullable: true + description: |- + Set to 'course' to treat this program as a course in APIs. + + * `course` - course + oneOf: + - $ref: '#/components/schemas/DisplayModeEnum' + - $ref: '#/components/schemas/BlankEnum' + - $ref: '#/components/schemas/NullEnum' topics: type: array items: @@ -8114,6 +8141,16 @@ components: readOnly: true live: type: boolean + display_mode: + nullable: true + description: |- + Set to 'course' to treat this program as a course in APIs. + + * `course` - course + oneOf: + - $ref: '#/components/schemas/DisplayModeEnum' + - $ref: '#/components/schemas/BlankEnum' + - $ref: '#/components/schemas/NullEnum' topics: type: array items: @@ -8343,6 +8380,16 @@ components: maxLength: 255 live: type: boolean + display_mode: + nullable: true + description: |- + Set to 'course' to treat this program as a course in APIs. + + * `course` - course + oneOf: + - $ref: '#/components/schemas/DisplayModeEnum' + - $ref: '#/components/schemas/BlankEnum' + - $ref: '#/components/schemas/NullEnum' required: - id - readable_id diff --git a/openapi/specs/v2.yaml b/openapi/specs/v2.yaml index 37072e1eb3..a7e9f6448d 100644 --- a/openapi/specs/v2.yaml +++ b/openapi/specs/v2.yaml @@ -3237,6 +3237,16 @@ components: type: type: string readOnly: true + display_mode: + nullable: true + description: |- + Set to 'course' to treat this program as a course in APIs. + + * `course` - course + oneOf: + - $ref: '#/components/schemas/DisplayModeEnum' + - $ref: '#/components/schemas/BlankEnum' + - $ref: '#/components/schemas/NullEnum' required: - id - readable_id @@ -4565,6 +4575,13 @@ components: - percent-off - dollars-off - fixed-price + DisplayModeEnum: + enum: + - course + type: string + description: '* `course` - course' + x-enum-descriptions: + - course EnrollmentMode: type: object description: Enrollment mode serializer. @@ -7839,6 +7856,16 @@ components: readOnly: true live: type: boolean + display_mode: + nullable: true + description: |- + Set to 'course' to treat this program as a course in APIs. + + * `course` - course + oneOf: + - $ref: '#/components/schemas/DisplayModeEnum' + - $ref: '#/components/schemas/BlankEnum' + - $ref: '#/components/schemas/NullEnum' topics: type: array items: @@ -8114,6 +8141,16 @@ components: readOnly: true live: type: boolean + display_mode: + nullable: true + description: |- + Set to 'course' to treat this program as a course in APIs. + + * `course` - course + oneOf: + - $ref: '#/components/schemas/DisplayModeEnum' + - $ref: '#/components/schemas/BlankEnum' + - $ref: '#/components/schemas/NullEnum' topics: type: array items: @@ -8343,6 +8380,16 @@ components: maxLength: 255 live: type: boolean + display_mode: + nullable: true + description: |- + Set to 'course' to treat this program as a course in APIs. + + * `course` - course + oneOf: + - $ref: '#/components/schemas/DisplayModeEnum' + - $ref: '#/components/schemas/BlankEnum' + - $ref: '#/components/schemas/NullEnum' required: - id - readable_id From c0458bab1534c1ccb565cb66794622ef622b2c32 Mon Sep 17 00:00:00 2001 From: Doof Date: Tue, 10 Mar 2026 15:32:22 +0000 Subject: [PATCH 8/8] Release 0.141.0 --- RELEASE.rst | 11 +++++++++++ main/settings.py | 2 +- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/RELEASE.rst b/RELEASE.rst index ba93e846da..114cf9b183 100644 --- a/RELEASE.rst +++ b/RELEASE.rst @@ -1,6 +1,17 @@ Release Notes ============= +Version 0.141.0 +--------------- + +- 10405 add display mode field for mitxonline programs returned in the api (#3358) +- Add server-side redirect for /dashboard to learn.mit.edu (#3356) +- update olposthog (#3370) +- Check for a null start date; emit nothing in that case (#3368) +- Don't attempt to add users to Keycloak orgs if there's no org to add to (#3359) +- Fix sometimes flaky program filter test bug (#3364) +- Add search and filters to enrollment code attachment Django admin (#3362) + Version 0.140.1 (Released March 09, 2026) --------------- diff --git a/main/settings.py b/main/settings.py index d7888f1679..b4a7f795cf 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 = "0.140.1" +VERSION = "0.141.0" log = logging.getLogger()