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

Expand Down
20 changes: 20 additions & 0 deletions b2b/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
3 changes: 3 additions & 0 deletions b2b/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
8 changes: 5 additions & 3 deletions b2b/api_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from datetime import timedelta
from decimal import Decimal
from uuid import uuid4
from zoneinfo import ZoneInfo

import faker
Expand Down Expand Up @@ -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(
Expand Down
2 changes: 1 addition & 1 deletion b2b/factories.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions b2b/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
26 changes: 26 additions & 0 deletions b2b/models_test.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
"""Tests for models."""

from uuid import uuid4

import faker
import pytest

Expand Down Expand Up @@ -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
1 change: 1 addition & 0 deletions conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
11 changes: 9 additions & 2 deletions courses/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]


Expand Down
3 changes: 3 additions & 0 deletions courses/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}(?P<run_tag>R\d+)$"
Expand Down
1 change: 1 addition & 0 deletions courses/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -434,6 +434,7 @@ class Meta:
"enrollment_start",
"enrollment_end",
"b2b_only",
"display_mode",
"enrollment_modes",
]

Expand Down
23 changes: 23 additions & 0 deletions courses/migrations/0087_program_display_mode.py
Original file line number Diff line number Diff line change
@@ -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."
),
),
),
]
23 changes: 23 additions & 0 deletions courses/migrations/0088_alter_program_display_mode.py
Original file line number Diff line number Diff line change
@@ -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,
),
),
]
8 changes: 8 additions & 0 deletions courses/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down
1 change: 1 addition & 0 deletions courses/serializers/v1/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ class Meta:
"readable_id",
"id",
"type",
"display_mode",
]


Expand Down
1 change: 1 addition & 0 deletions courses/serializers/v1/base_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ def test_base_program_serializer():
"readable_id": program.readable_id,
"id": program.id,
"type": "program",
"display_mode": None,
}


Expand Down
1 change: 1 addition & 0 deletions courses/serializers/v2/programs.py
Original file line number Diff line number Diff line change
Expand Up @@ -532,6 +532,7 @@ class Meta:
"certificate_type",
"departments",
"live",
"display_mode",
"topics",
"availability",
"start_date",
Expand Down
1 change: 1 addition & 0 deletions courses/serializers/v2/programs_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
)

Expand Down
1 change: 1 addition & 0 deletions courses/serializers/v3/programs.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ class Meta:
"id",
"program_type",
"live",
"display_mode",
]


Expand Down
1 change: 1 addition & 0 deletions courses/serializers/v3/programs_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions courses/views/v2/views_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
3 changes: 2 additions & 1 deletion frontend/public/src/containers/pages/DashboardPage.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Expand Down
29 changes: 27 additions & 2 deletions frontend/public/src/containers/pages/DashboardPage_test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand Down
4 changes: 4 additions & 0 deletions frontend/public/src/lib/util.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 2 additions & 0 deletions main/features.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,5 @@
ENABLE_VERIFIABLE_CREDENTIALS_PROVISIONING = (
"mitxonline-verifiable-credentials-provisioning"
)

REDIRECT_LEARN_DASHBOARD = "redirect-to-learn-dashboard"
Loading
Loading