diff --git a/RELEASE.rst b/RELEASE.rst
index a7db1b8414..d4b02bedc1 100644
--- a/RELEASE.rst
+++ b/RELEASE.rst
@@ -1,6 +1,15 @@
Release Notes
=============
+Version 0.47.7
+--------------
+
+- remove org dashboard feature flag (#2654)
+- Avoid double slashes in paths (#2652)
+- use next_start_date when available in learning resource drawer (#2619)
+- Fix a BannerPage background issue (#2651)
+- Don't publish Professional Ed resources with blank run ids (#2637)
+
Version 0.47.5 (Released October 28, 2025)
--------------
diff --git a/env/frontend.env b/env/frontend.env
index 635d73a377..58ae74be6d 100644
--- a/env/frontend.env
+++ b/env/frontend.env
@@ -1,7 +1,7 @@
NODE_ENV=development
PORT=8062
SENTRY_ENV=dev # Re-enable sentry
-NEXT_PUBLIC_OPTIMIZE_IMAGES="true"
+NEXT_PUBLIC_OPTIMIZE_IMAGES="false"
# Environment variables with `NEXT_PUBLIC_` prefix are exposed to the client side
NEXT_PUBLIC_ORIGIN=${MITOL_APP_BASE_URL}
diff --git a/frontends/main/src/app-pages/DashboardPage/DashboardLayout.test.tsx b/frontends/main/src/app-pages/DashboardPage/DashboardLayout.test.tsx
index 13d4eccaf5..9b04084e3e 100644
--- a/frontends/main/src/app-pages/DashboardPage/DashboardLayout.test.tsx
+++ b/frontends/main/src/app-pages/DashboardPage/DashboardLayout.test.tsx
@@ -21,17 +21,11 @@ import {
} from "@/common/urls"
import { faker } from "@faker-js/faker/locale/en"
import invariant from "tiny-invariant"
-import { useFeatureFlagEnabled } from "posthog-js/react"
import { OrganizationPage, ContractPage } from "@mitodl/mitxonline-api-axios/v2"
jest.mock("posthog-js/react")
-const mockedUseFeatureFlagEnabled = jest.mocked(useFeatureFlagEnabled)
describe("DashboardLayout", () => {
- beforeEach(() => {
- mockedUseFeatureFlagEnabled.mockReturnValue(false)
- })
-
type SetupOptions = {
initialUrl?: string
organizations?: OrganizationPage[]
@@ -68,9 +62,6 @@ describe("DashboardLayout", () => {
})
test("Renders the expected tab links and labels", async () => {
- // Enable organization dashboard feature flag for this test
- mockedUseFeatureFlagEnabled.mockReturnValue(true)
-
const organizations = [
mitxOnlineFactories.organizations.organization({
slug: "org-test-org",
diff --git a/frontends/main/src/app-pages/DashboardPage/DashboardLayout.tsx b/frontends/main/src/app-pages/DashboardPage/DashboardLayout.tsx
index a105940280..c6a5ece287 100644
--- a/frontends/main/src/app-pages/DashboardPage/DashboardLayout.tsx
+++ b/frontends/main/src/app-pages/DashboardPage/DashboardLayout.tsx
@@ -36,8 +36,6 @@ import {
import dynamic from "next/dynamic"
import { MitxOnlineUser, mitxUserQueries } from "api/mitxonline-hooks/user"
import { useUserMe } from "api/hooks/user"
-import { useFeatureFlagEnabled } from "posthog-js/react"
-import { FeatureFlags } from "@/common/feature_flags"
import { contractQueries } from "api/mitxonline-hooks/contracts"
import { useQuery } from "@tanstack/react-query"
import { ContractPage } from "@mitodl/mitxonline-api-axios/v2"
@@ -241,12 +239,11 @@ type TabData = {
}
}
const getTabData = (
- orgsEnabled: boolean = false,
user?: MitxOnlineUser,
contracts?: ContractPage[],
): TabData[] => {
const orgTabs =
- orgsEnabled && user && contracts
+ user && contracts
? user?.b2b_organizations.map((org) => {
const orgContracts = contracts?.filter(
(contract) => contract.organization === org.id,
@@ -317,7 +314,6 @@ const DashboardPage: React.FC<{
}> = ({ children }) => {
const pathname = usePathname()
const { isLoading: isLoadingUser, data: user } = useUserMe()
- const orgsEnabled = useFeatureFlagEnabled(FeatureFlags.OrganizationDashboard)
const { data: mitxOnlineUser, isLoading: isLoadingMitxOnlineUser } = useQuery(
{
...mitxUserQueries.me(),
@@ -330,15 +326,9 @@ const DashboardPage: React.FC<{
const tabData = useMemo(
() =>
isLoadingMitxOnlineUser || isLoadingContracts
- ? getTabData(orgsEnabled)
- : getTabData(orgsEnabled, mitxOnlineUser, contracts),
- [
- isLoadingMitxOnlineUser,
- isLoadingContracts,
- orgsEnabled,
- mitxOnlineUser,
- contracts,
- ],
+ ? getTabData()
+ : getTabData(mitxOnlineUser, contracts),
+ [isLoadingMitxOnlineUser, isLoadingContracts, mitxOnlineUser, contracts],
)
const tabValue = useMemo(() => {
diff --git a/frontends/main/src/app-pages/DashboardPage/HomeContent.test.tsx b/frontends/main/src/app-pages/DashboardPage/HomeContent.test.tsx
index d07267c2c1..a81e290173 100644
--- a/frontends/main/src/app-pages/DashboardPage/HomeContent.test.tsx
+++ b/frontends/main/src/app-pages/DashboardPage/HomeContent.test.tsx
@@ -210,7 +210,6 @@ describe("HomeContent", () => {
setupAPIs()
mockedUseFeatureFlagEnabled.mockImplementation((flag) => {
if (flag === "enrollment-dashboard") return enrollmentsEnabled
- if (flag === "mitlearn-organization-dashboard") return false // Disable org cards to avoid image issues
return false
})
diff --git a/frontends/main/src/app-pages/DashboardPage/HomeContent.tsx b/frontends/main/src/app-pages/DashboardPage/HomeContent.tsx
index 85bbaa09aa..377d1eb7e9 100644
--- a/frontends/main/src/app-pages/DashboardPage/HomeContent.tsx
+++ b/frontends/main/src/app-pages/DashboardPage/HomeContent.tsx
@@ -70,7 +70,6 @@ const HomeContent: React.FC = () => {
const { isLoading: isLoadingProfile, data: user } = useUserMe()
const topics = user?.profile?.preference_search_filters.topic
const certification = user?.profile?.preference_search_filters.certification
- const showOrgs = useFeatureFlagEnabled(FeatureFlags.OrganizationDashboard)
const showEnrollments = useFeatureFlagEnabled(
FeatureFlags.EnrollmentDashboard,
)
@@ -89,7 +88,7 @@ const HomeContent: React.FC = () => {
- {showOrgs ? : null}
+
{showEnrollments ? : null}
false)
-
describe("OrganizationContent", () => {
beforeEach(() => {
- mockedUseFeatureFlagEnabled.mockReturnValue(true)
setMockResponse.get(urls.enrollment.enrollmentsList(), [])
setMockResponse.get(urls.enrollment.enrollmentsListV2(), [])
setMockResponse.get(urls.programEnrollments.enrollmentsList(), [])
diff --git a/frontends/main/src/app-pages/DashboardPage/OrganizationContent.tsx b/frontends/main/src/app-pages/DashboardPage/OrganizationContent.tsx
index 2653118675..2c5d649d7c 100644
--- a/frontends/main/src/app-pages/DashboardPage/OrganizationContent.tsx
+++ b/frontends/main/src/app-pages/DashboardPage/OrganizationContent.tsx
@@ -3,8 +3,6 @@
import React, { useEffect } from "react"
import DOMPurify from "isomorphic-dompurify"
import Image from "next/image"
-import { useFeatureFlagEnabled } from "posthog-js/react"
-import { FeatureFlags } from "@/common/feature_flags"
import { useQueries, useQuery } from "@tanstack/react-query"
import {
programsQueries,
@@ -360,9 +358,6 @@ type OrganizationContentInternalProps = {
const OrganizationContentInternal: React.FC<
OrganizationContentInternalProps
> = ({ org }) => {
- const isOrgDashboardEnabled = useFeatureFlagEnabled(
- FeatureFlags.OrganizationDashboard,
- )
const orgId = org.id
const contracts = useQuery(contractQueries.contractsList())
const orgContracts = contracts.data?.filter(
@@ -379,8 +374,6 @@ const OrganizationContentInternal: React.FC<
programCollectionQueries.programCollectionsList({}),
)
- if (!isOrgDashboardEnabled) return null
-
const transformedPrograms = programs.data?.results
.filter((program) => program.collections.length === 0)
.map((program) => transform.mitxonlineProgram(program))
diff --git a/frontends/main/src/common/feature_flags.ts b/frontends/main/src/common/feature_flags.ts
index 39d09ae681..f45b5f5c03 100644
--- a/frontends/main/src/common/feature_flags.ts
+++ b/frontends/main/src/common/feature_flags.ts
@@ -8,7 +8,6 @@ export enum FeatureFlags {
RecommendationBot = "recommendation-bot",
HomePageRecommendationBot = "home-page-recommendation-bot",
EnrollmentDashboard = "enrollment-dashboard",
- OrganizationDashboard = "mitlearn-organization-dashboard",
VideoShorts = "video-shorts",
ProductPageCourse = "product-page-course",
}
diff --git a/frontends/main/src/page-components/LearningResourceExpanded/InfoSection.test.tsx b/frontends/main/src/page-components/LearningResourceExpanded/InfoSection.test.tsx
index 06383cfc32..bf1c5645ed 100644
--- a/frontends/main/src/page-components/LearningResourceExpanded/InfoSection.test.tsx
+++ b/frontends/main/src/page-components/LearningResourceExpanded/InfoSection.test.tsx
@@ -115,6 +115,35 @@ describe("Learning resource info section start date", () => {
within(section).getByText(runDate)
})
+ test("Uses next_start_date when available", () => {
+ const course = {
+ ...courses.free.dated,
+ next_start_date: "2024-03-15T00:00:00Z",
+ }
+ renderWithTheme()
+
+ const section = screen.getByTestId("drawer-info-items")
+ within(section).getByText("Starts:")
+ within(section).getByText("March 15, 2024")
+ })
+
+ test("Falls back to run date when next_start_date is null", () => {
+ const course = {
+ ...courses.free.dated,
+ next_start_date: null,
+ }
+ const run = course.runs?.[0]
+ invariant(run)
+ const runDate = formatRunDate(run, false)
+ invariant(runDate)
+ renderWithTheme()
+
+ const section = screen.getByTestId("drawer-info-items")
+ within(section).getByText("Starts:")
+ within(section).getByText(runDate)
+ expect(within(section).queryByText("March 15, 2024")).toBeNull()
+ })
+
test("As taught in date(s)", () => {
const course = courses.free.anytime
const run = course.runs?.[0]
@@ -151,6 +180,31 @@ describe("Learning resource info section start date", () => {
})
})
+ test("Multiple run dates with next_start_date uses next_start_date as first date", () => {
+ const course = {
+ ...courses.multipleRuns.sameData,
+ next_start_date: "2024-01-15T00:00:00Z",
+ }
+ const sortedDates = course.runs
+ ?.sort((a, b) => {
+ if (a?.start_date && b?.start_date) {
+ return Date.parse(a.start_date) - Date.parse(b.start_date)
+ }
+ return 0
+ })
+ .map((run) => formatRunDate(run, false))
+ .filter((date) => date !== null)
+
+ // First date should be next_start_date, second should be original second date
+ const expectedDateText = `January 15, 2024${SEPARATOR}${sortedDates?.[1]}Show more`
+ renderWithTheme()
+
+ const section = screen.getByTestId("drawer-info-items")
+ within(section).getAllByText((_content, node) => {
+ return node?.textContent === expectedDateText || false
+ })
+ })
+
test("If data is different then dates, formats, locations and prices are not shown", () => {
const course = courses.multipleRuns.differentData
renderWithTheme()
diff --git a/frontends/main/src/page-components/LearningResourceExpanded/InfoSection.tsx b/frontends/main/src/page-components/LearningResourceExpanded/InfoSection.tsx
index 3b68e7badb..f07ab87a22 100644
--- a/frontends/main/src/page-components/LearningResourceExpanded/InfoSection.tsx
+++ b/frontends/main/src/page-components/LearningResourceExpanded/InfoSection.tsx
@@ -31,6 +31,7 @@ import {
getLearningResourcePrices,
showStartAnytime,
NoSSR,
+ formatDate,
} from "ol-utilities"
import { theme, Link } from "ol-components"
import DifferingRunsTable from "./DifferingRunsTable"
@@ -179,7 +180,7 @@ const totalRunsWithDates = (resource: LearningResource) => {
const RunDates: React.FC<{ resource: LearningResource }> = ({ resource }) => {
const [showingMore, setShowingMore] = useState(false)
- const sortedDates = resource.runs
+ let sortedDates = resource.runs
?.sort((a, b) => {
if (a?.start_date && b?.start_date) {
return Date.parse(a.start_date) - Date.parse(b.start_date)
@@ -188,6 +189,18 @@ const RunDates: React.FC<{ resource: LearningResource }> = ({ resource }) => {
})
.map((run) => formatRunDate(run, showStartAnytime(resource)))
.filter((date) => date !== null)
+
+ const nextStartDate = resource.next_start_date
+ ? formatDate(resource.next_start_date, "MMMM DD, YYYY")
+ : null
+
+ if (sortedDates && nextStartDate) {
+ // Replace the first date with next_start_date
+ sortedDates = [nextStartDate, ...sortedDates.slice(1)]
+ }
+ if (!sortedDates || sortedDates.length === 0) {
+ return null
+ }
const totalDates = sortedDates?.length || 0
const showMore = totalDates > 2
if (showMore) {
diff --git a/frontends/ol-components/src/components/Banner/Banner.tsx b/frontends/ol-components/src/components/Banner/Banner.tsx
index 6fd84cae5d..559c00f1d7 100644
--- a/frontends/ol-components/src/components/Banner/Banner.tsx
+++ b/frontends/ol-components/src/components/Banner/Banner.tsx
@@ -11,11 +11,21 @@ const SubHeader = styled(Typography)({
})
type BannerBackgroundProps = {
+ /**
+ * Background image src, url(...), or image-set(...).
+ */
backgroundUrl?: string
backgroundSize?: string
backgroundDim?: number
}
+const standardizeBackgroundUrl = (url: string) => {
+ if (url.startsWith("url(") || url.startsWith("image-set(")) {
+ return url
+ }
+ return url.startsWith("image-set(") ? url : `url('${url}')`
+}
+
/**
* This is a full-width banner component that takes a background image URL.
*/
@@ -26,9 +36,7 @@ const BannerBackground = styled.div(
backgroundSize = "cover",
backgroundDim = 0,
}) => {
- const backgroundUrlFn = backgroundUrl.startsWith("image-set(")
- ? backgroundUrl
- : `url('${backgroundUrl}')`
+ const backgroundUrlFn = standardizeBackgroundUrl(backgroundUrl)
return {
backgroundAttachment: "fixed",
diff --git a/learning_resources/etl/mitpe.py b/learning_resources/etl/mitpe.py
index c25911a1f3..a604fb6ebf 100644
--- a/learning_resources/etl/mitpe.py
+++ b/learning_resources/etl/mitpe.py
@@ -233,10 +233,13 @@ def _transform_runs(resource_data: dict) -> list[dict]:
duration = parse_resource_duration(resource_data.get("duration"))
published_runs = []
for run_data in runs_data:
+ run_id = run_data[0]
start = parse_date(run_data[1])
end = parse_date(run_data[2])
enrollment_end = parse_date(run_data[3])
- published = (not end and not enrollment_end) or (now <= (enrollment_end or end))
+ published = run_id and (
+ (not end and not enrollment_end) or (now <= (enrollment_end or end))
+ )
if published:
published_runs.append(
{
@@ -283,8 +286,8 @@ def transform_course(resource_data: dict) -> dict or None:
Returns:
dict: transformed course data if it has any viable runs
"""
- runs = _transform_runs(resource_data)
- if runs:
+ published_runs = _transform_runs(resource_data)
+ if published_runs:
return {
"readable_id": resource_data["uuid"],
"offered_by": copy.deepcopy(OFFERED_BY),
@@ -303,7 +306,7 @@ def transform_course(resource_data: dict) -> dict or None:
"delivery": transform_delivery(resource_data["learning_format"]),
"published": True,
"topics": parse_topics(resource_data),
- "runs": runs,
+ "runs": published_runs,
"format": [Format.asynchronous.name],
"pace": [Pace.instructor_paced.name],
"availability": Availability.dated.name,
@@ -321,8 +324,8 @@ def transform_program(resource_data: dict) -> dict or None:
Returns:
dict: transformed program data
"""
- runs = _transform_runs(resource_data)
- if runs:
+ published_runs = _transform_runs(resource_data)
+ if published_runs:
return {
"readable_id": resource_data["uuid"],
"offered_by": copy.deepcopy(OFFERED_BY),
@@ -343,7 +346,7 @@ def transform_program(resource_data: dict) -> dict or None:
"delivery": transform_delivery(resource_data["learning_format"]),
"published": True,
"topics": parse_topics(resource_data),
- "runs": runs,
+ "runs": published_runs,
"format": [Format.asynchronous.name],
"pace": [Pace.instructor_paced.name],
"availability": Availability.dated.name,
diff --git a/learning_resources/etl/mitpe_test.py b/learning_resources/etl/mitpe_test.py
index b36322d0c3..6978f3df0b 100644
--- a/learning_resources/etl/mitpe_test.py
+++ b/learning_resources/etl/mitpe_test.py
@@ -193,6 +193,22 @@ def read_json(file_path):
)
+@pytest.fixture
+def mitpe_offeror_and_topics(mocker):
+ """Create the MIT Professional Education offeror and 2 topics"""
+ offeror = LearningResourceOfferorFactory.create(code="mitpe")
+ LearningResourceTopicMappingFactory.create(
+ offeror=offeror,
+ topic=LearningResourceTopicFactory.create(name="Product Innovation"),
+ topic_name="Technology Innovation",
+ )
+ LearningResourceTopicMappingFactory.create(
+ offeror=offeror,
+ topic=LearningResourceTopicFactory.create(name="Data Science"),
+ topic_name="Data Science",
+ )
+
+
@pytest.mark.parametrize("prof_ed_api_url", ["http://pro_edd_api.com", None])
def test_extract(settings, mock_fetch_data, prof_ed_api_url):
"""Test extract function"""
@@ -213,19 +229,8 @@ def test_extract(settings, mock_fetch_data, prof_ed_api_url):
@pytest.mark.django_db
-def test_transform(mock_fetch_data):
+def test_transform(mock_fetch_data, mitpe_offeror_and_topics):
"""Test transform function, and effectively most other functions"""
- offeror = LearningResourceOfferorFactory.create(code="mitpe")
- LearningResourceTopicMappingFactory.create(
- offeror=offeror,
- topic=LearningResourceTopicFactory.create(name="Product Innovation"),
- topic_name="Technology Innovation",
- )
- LearningResourceTopicMappingFactory.create(
- offeror=offeror,
- topic=LearningResourceTopicFactory.create(name="Data Science"),
- topic_name="Data Science",
- )
extracted = mitpe.extract()
assert len(extracted) == 3
courses, programs = mitpe.transform(extracted)
@@ -233,3 +238,13 @@ def test_transform(mock_fetch_data):
sorted(courses, key=lambda course: course["readable_id"]), EXPECTED_COURSES
)
assert_json_equal(programs, EXPECTED_PROGRAMS)
+
+
+@pytest.mark.django_db
+def test_transform__course_no_run_ids(mock_fetch_data, mitpe_offeror_and_topics):
+ """Resources with no run IDs should not be published"""
+ extracted = mitpe.extract()
+ assert len(extracted) == 3
+ for resource in extracted:
+ resource["run__readable_id"] = ""
+ assert mitpe.transform(extracted) == ([], [])
diff --git a/main/settings.py b/main/settings.py
index 2f43462be4..8cd321eeb6 100644
--- a/main/settings.py
+++ b/main/settings.py
@@ -34,7 +34,7 @@
from main.settings_pluggy import * # noqa: F403
from openapi.settings_spectacular import open_spectacular_settings
-VERSION = "0.47.5"
+VERSION = "0.47.7"
log = logging.getLogger()
diff --git a/video_shorts/factories.py b/video_shorts/factories.py
index 4c0c415df7..eec04dc85f 100644
--- a/video_shorts/factories.py
+++ b/video_shorts/factories.py
@@ -20,12 +20,12 @@ class VideoShortFactory(DjangoModelFactory):
datetime.now(tz=UTC),
)
thumbnail_url = factory.LazyAttribute(
- lambda obj: f"https://example.com/thumbnails/{obj.youtube_id}.jpg"
+ lambda obj: f"shorts/{obj.youtube_id}/{obj.youtube_id}.jpg"
)
thumbnail_height = FuzzyInteger(360, 720)
thumbnail_width = FuzzyInteger(480, 1280)
video_url = factory.LazyAttribute(
- lambda obj: f"https://example.com/videos/{obj.youtube_id}.mp4"
+ lambda obj: f"shorts/{obj.youtube_id}/{obj.youtube_id}.mp4"
)
class Meta:
diff --git a/video_shorts/serializers.py b/video_shorts/serializers.py
index 4b9713758b..a020924da0 100644
--- a/video_shorts/serializers.py
+++ b/video_shorts/serializers.py
@@ -1,5 +1,7 @@
"""Serializers for video shorts"""
+from urllib.parse import urljoin
+
from django.conf import settings
from rest_framework import serializers
@@ -46,7 +48,7 @@ def to_internal_value(self, data):
)
# Use relative URLs so they work regardless of domain
- base_path = f"/{settings.VIDEO_SHORTS_S3_PREFIX}/{youtube_id}/"
+ base_path = urljoin(f"/{settings.VIDEO_SHORTS_S3_PREFIX}/", f"{youtube_id}/")
transformed_data = {
"youtube_id": youtube_id,
"title": snippet.get("title"),
diff --git a/video_shorts/serializers_test.py b/video_shorts/serializers_test.py
index 183d5c5352..6de5a5a28b 100644
--- a/video_shorts/serializers_test.py
+++ b/video_shorts/serializers_test.py
@@ -22,10 +22,10 @@ def test_video_short_serializer_read():
title="Test Video",
description="Test description",
published_at=datetime(2024, 1, 15, 12, 0, 0, tzinfo=UTC),
- thumbnail_url="https://example.com/thumb.jpg",
+ thumbnail_url="/shorts/test_123/test_123.jpg",
thumbnail_height=360,
thumbnail_width=480,
- video_url="https://example.com/video.mp4",
+ video_url="/shorts/test_123/test_123.mp4",
)
serializer = VideoShortSerializer(video_short)
@@ -41,10 +41,10 @@ def test_video_short_serializer_read():
"title": "Test Video",
"description": "Test description",
"published_at": "2024-01-15T12:00:00Z",
- "thumbnail_url": "https://example.com/thumb.jpg",
+ "thumbnail_url": "/shorts/test_123/test_123.jpg",
"thumbnail_height": 360,
"thumbnail_width": 480,
- "video_url": "https://example.com/video.mp4",
+ "video_url": "/shorts/test_123/test_123.mp4",
},
)
@@ -56,10 +56,10 @@ def test_video_short_serializer_create():
"title": "Created Video",
"description": "Created description",
"published_at": "2024-01-15T12:00:00Z",
- "thumbnail_url": "https://example.com/created_thumb.jpg",
+ "thumbnail_url": "/shorts/create_test/create_test.jpg",
"thumbnail_height": 720,
"thumbnail_width": 1280,
- "video_url": "https://example.com/created_video.mp4",
+ "video_url": "/shorts/create_test/create_test.mp4",
}
serializer = VideoShortSerializer(data=data)
@@ -120,9 +120,12 @@ def test_video_short_serializer_update():
assert_json_equal(result_data, data)
-def test_youtube_metadata_serializer(settings, sample_youtube_metadata):
+@pytest.mark.parametrize(
+ "prefix", ["youtube_shorts/", "youtube_shorts", "youtube_shorts//"]
+)
+def test_youtube_metadata_serializer(settings, sample_youtube_metadata, prefix):
"""Test YouTubeMetadataSerializer transforms YouTube API data correctly"""
- settings.VIDEO_SHORTS_S3_PREFIX = "youtube_shorts"
+ settings.VIDEO_SHORTS_S3_PREFIX = prefix
serializer = YouTubeMetadataSerializer(data=sample_youtube_metadata)
assert serializer.is_valid()