From 01c3d29ced15d66c48dc9768fe9c6e85dd291398 Mon Sep 17 00:00:00 2001 From: Shankar Ambady Date: Fri, 1 May 2026 15:37:06 -0400 Subject: [PATCH 1/9] qdrant: Automatically compute optimizer settings (#3273) * adding buckets for dynamic optimizer settings * fix test * add test * Update vector_search/conftest.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * skip if already optimized --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- vector_search/conftest.py | 7 ++++ vector_search/constants.py | 21 +++++++++++ vector_search/utils.py | 55 ++++++++++++++++++++++++++++ vector_search/utils_test.py | 72 +++++++++++++++++++++++++++++++++++++ 4 files changed, 155 insertions(+) diff --git a/vector_search/conftest.py b/vector_search/conftest.py index ef62997ffd..be3651cf1f 100644 --- a/vector_search/conftest.py +++ b/vector_search/conftest.py @@ -40,6 +40,13 @@ def _use_test_qdrant_settings(settings, mocker): ) mock_qdrant = mocker.patch("qdrant_client.QdrantClient") mocker.patch("vector_search.utils.SemanticChunker") + mocker.patch( + "vector_search.utils.compute_optimizer_settings", + return_value={ + "indexing_threshold": 10, + "flush_interval_sec": 5, + }, + ) mock_qdrant.scroll.return_value = [ [], diff --git a/vector_search/constants.py b/vector_search/constants.py index bf2deb09de..e740589373 100644 --- a/vector_search/constants.py +++ b/vector_search/constants.py @@ -142,3 +142,24 @@ # Maximum value of offset + limit accepted by paginated vector search MAX_RESULT_WINDOW = 1000 + +# Qdrant Optimizer Settings +# Thresholds for points per shard +QDRANT_OPTIMIZER_THRESHOLD_SMALL = 50_000 +QDRANT_OPTIMIZER_THRESHOLD_MEDIUM = 500_000 +QDRANT_OPTIMIZER_THRESHOLD_LARGE = 2_000_000 + +# Target segment sizes +QDRANT_OPTIMIZER_SEGMENT_SMALL = 20_000 +QDRANT_OPTIMIZER_SEGMENT_MEDIUM = 60_000 +QDRANT_OPTIMIZER_SEGMENT_LARGE = 120_000 +QDRANT_OPTIMIZER_SEGMENT_XLARGE = 250_000 + +# Flush intervals +QDRANT_OPTIMIZER_FLUSH_INTERVAL_SMALL = 15 +QDRANT_OPTIMIZER_FLUSH_INTERVAL_MEDIUM = 20 +QDRANT_OPTIMIZER_FLUSH_INTERVAL_LARGE = 25 +QDRANT_OPTIMIZER_FLUSH_INTERVAL_XLARGE = 30 + +# Indexing threshold ratio +QDRANT_OPTIMIZER_INDEXING_THRESHOLD_RATIO = 0.4 diff --git a/vector_search/utils.py b/vector_search/utils.py index 89791ab06e..c235595a04 100644 --- a/vector_search/utils.py +++ b/vector_search/utils.py @@ -38,6 +38,18 @@ QDRANT_CONTENT_FILE_INDEXES, QDRANT_CONTENT_FILE_PARAM_MAP, QDRANT_LEARNING_RESOURCE_INDEXES, + QDRANT_OPTIMIZER_FLUSH_INTERVAL_LARGE, + QDRANT_OPTIMIZER_FLUSH_INTERVAL_MEDIUM, + QDRANT_OPTIMIZER_FLUSH_INTERVAL_SMALL, + QDRANT_OPTIMIZER_FLUSH_INTERVAL_XLARGE, + QDRANT_OPTIMIZER_INDEXING_THRESHOLD_RATIO, + QDRANT_OPTIMIZER_SEGMENT_LARGE, + QDRANT_OPTIMIZER_SEGMENT_MEDIUM, + QDRANT_OPTIMIZER_SEGMENT_SMALL, + QDRANT_OPTIMIZER_SEGMENT_XLARGE, + QDRANT_OPTIMIZER_THRESHOLD_LARGE, + QDRANT_OPTIMIZER_THRESHOLD_MEDIUM, + QDRANT_OPTIMIZER_THRESHOLD_SMALL, QDRANT_RESOURCE_PARAM_MAP, QDRANT_TOPIC_INDEXES, RESOURCES_COLLECTION_NAME, @@ -127,6 +139,48 @@ def points_generator( yield models.PointStruct(**point_data) +def compute_optimizer_settings(point_count: int, shard_number: int): + points_per_shard = max(point_count // shard_number, 1) + + # Determine target segment size + if points_per_shard < QDRANT_OPTIMIZER_THRESHOLD_SMALL: + target_segment = QDRANT_OPTIMIZER_SEGMENT_SMALL + flush_interval = QDRANT_OPTIMIZER_FLUSH_INTERVAL_SMALL + elif points_per_shard < QDRANT_OPTIMIZER_THRESHOLD_MEDIUM: + target_segment = QDRANT_OPTIMIZER_SEGMENT_MEDIUM + flush_interval = QDRANT_OPTIMIZER_FLUSH_INTERVAL_MEDIUM + elif points_per_shard < QDRANT_OPTIMIZER_THRESHOLD_LARGE: + target_segment = QDRANT_OPTIMIZER_SEGMENT_LARGE + flush_interval = QDRANT_OPTIMIZER_FLUSH_INTERVAL_LARGE + else: + target_segment = QDRANT_OPTIMIZER_SEGMENT_XLARGE + flush_interval = QDRANT_OPTIMIZER_FLUSH_INTERVAL_XLARGE + + indexing_threshold = int(target_segment * QDRANT_OPTIMIZER_INDEXING_THRESHOLD_RATIO) + + return { + "indexing_threshold": indexing_threshold, + "flush_interval_sec": flush_interval, + } + + +def tune_collection(client, collection_name): + info = client.get_collection(collection_name) + point_count = info.points_count + shard_number = info.config.params.shard_number + desired = compute_optimizer_settings(point_count, shard_number) + current = info.config.optimizer_config + if ( + current.indexing_threshold == desired["indexing_threshold"] + and current.flush_interval_sec == desired["flush_interval_sec"] + ): + return + client.update_collection( + collection_name=collection_name, + optimizer_config=models.OptimizersConfigDiff(**desired), + ) + + def create_qdrant_collections(force_recreate): """ Create or recreate QDrant collections @@ -187,6 +241,7 @@ def create_qdrant_collection(collection_name, force_recreate): ), hnsw_config=models.HnswConfigDiff(on_disk=False), ) + tune_collection(client, collection_name) def update_qdrant_indexes(): diff --git a/vector_search/utils_test.py b/vector_search/utils_test.py index c9d2458a44..e2303e8851 100644 --- a/vector_search/utils_test.py +++ b/vector_search/utils_test.py @@ -33,6 +33,18 @@ QDRANT_CONTENT_FILE_INDEXES, QDRANT_CONTENT_FILE_PARAM_MAP, QDRANT_LEARNING_RESOURCE_INDEXES, + QDRANT_OPTIMIZER_FLUSH_INTERVAL_LARGE, + QDRANT_OPTIMIZER_FLUSH_INTERVAL_MEDIUM, + QDRANT_OPTIMIZER_FLUSH_INTERVAL_SMALL, + QDRANT_OPTIMIZER_FLUSH_INTERVAL_XLARGE, + QDRANT_OPTIMIZER_INDEXING_THRESHOLD_RATIO, + QDRANT_OPTIMIZER_SEGMENT_LARGE, + QDRANT_OPTIMIZER_SEGMENT_MEDIUM, + QDRANT_OPTIMIZER_SEGMENT_SMALL, + QDRANT_OPTIMIZER_SEGMENT_XLARGE, + QDRANT_OPTIMIZER_THRESHOLD_LARGE, + QDRANT_OPTIMIZER_THRESHOLD_MEDIUM, + QDRANT_OPTIMIZER_THRESHOLD_SMALL, QDRANT_RESOURCE_PARAM_MAP, RESOURCES_COLLECTION_NAME, ) @@ -46,6 +58,7 @@ _is_markdown_content, _resource_vector_hits, async_qdrant_aggregations, + compute_optimizer_settings, create_qdrant_collections, embed_learning_resources, embed_topics, @@ -63,6 +76,65 @@ pytestmark = pytest.mark.django_db +@pytest.mark.parametrize( + ("point_count", "shard_number", "segment_size", "flush_interval"), + [ + ( + 0, + 10, + QDRANT_OPTIMIZER_SEGMENT_SMALL, + QDRANT_OPTIMIZER_FLUSH_INTERVAL_SMALL, + ), + ( + QDRANT_OPTIMIZER_THRESHOLD_SMALL - 1, + 1, + QDRANT_OPTIMIZER_SEGMENT_SMALL, + QDRANT_OPTIMIZER_FLUSH_INTERVAL_SMALL, + ), + ( + QDRANT_OPTIMIZER_THRESHOLD_SMALL, + 1, + QDRANT_OPTIMIZER_SEGMENT_MEDIUM, + QDRANT_OPTIMIZER_FLUSH_INTERVAL_MEDIUM, + ), + ( + QDRANT_OPTIMIZER_THRESHOLD_MEDIUM, + 2, + QDRANT_OPTIMIZER_SEGMENT_MEDIUM, + QDRANT_OPTIMIZER_FLUSH_INTERVAL_MEDIUM, + ), + ( + QDRANT_OPTIMIZER_THRESHOLD_MEDIUM, + 1, + QDRANT_OPTIMIZER_SEGMENT_LARGE, + QDRANT_OPTIMIZER_FLUSH_INTERVAL_LARGE, + ), + ( + QDRANT_OPTIMIZER_THRESHOLD_LARGE, + 4, + QDRANT_OPTIMIZER_SEGMENT_LARGE, + QDRANT_OPTIMIZER_FLUSH_INTERVAL_LARGE, + ), + ( + QDRANT_OPTIMIZER_THRESHOLD_LARGE, + 1, + QDRANT_OPTIMIZER_SEGMENT_XLARGE, + QDRANT_OPTIMIZER_FLUSH_INTERVAL_XLARGE, + ), + ], +) +def test_compute_optimizer_settings( + point_count, shard_number, segment_size, flush_interval +): + """Optimizer settings are determined by point count per shard.""" + assert compute_optimizer_settings(point_count, shard_number) == { + "indexing_threshold": int( + segment_size * QDRANT_OPTIMIZER_INDEXING_THRESHOLD_RATIO + ), + "flush_interval_sec": flush_interval, + } + + @pytest.mark.parametrize("content_type", ["course", "content_file"]) def test_vector_point_id_used_for_embed(mocker, content_type): # test the vector ids we generate for embedding resources and files From 5fb8486f7f163a84cb82dd2232b05076c2d7d40f Mon Sep 17 00:00:00 2001 From: Ahtesham Quraish Date: Mon, 4 May 2026 15:48:26 +0500 Subject: [PATCH 2/9] fix: adjust duration position and remove institution label from the video series page and change the title of video and playlist on drawer (#3270) * fix: remove duration and institution label from the video series page --------- Co-authored-by: Ahtesham Quraish --- .../VideoPlaylistCollectionPage/MetaRow.tsx | 45 ------------- .../TopicChips.tsx | 32 ---------- .../VideoSeriesDetailPage.styled.ts | 7 +- .../VideoSeriesDetailPage.test.tsx | 64 ------------------- .../VideoSeriesDetailPage.tsx | 62 ++---------------- .../CallToActionSection.test.tsx | 4 +- .../CallToActionSection.tsx | 5 +- 7 files changed, 14 insertions(+), 205 deletions(-) delete mode 100644 frontends/main/src/app-pages/VideoPlaylistCollectionPage/MetaRow.tsx delete mode 100644 frontends/main/src/app-pages/VideoPlaylistCollectionPage/TopicChips.tsx diff --git a/frontends/main/src/app-pages/VideoPlaylistCollectionPage/MetaRow.tsx b/frontends/main/src/app-pages/VideoPlaylistCollectionPage/MetaRow.tsx deleted file mode 100644 index 46efa2dfb1..0000000000 --- a/frontends/main/src/app-pages/VideoPlaylistCollectionPage/MetaRow.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import React from "react" -import { theme, useMediaQuery } from "ol-components" -import * as Styled from "./VideoSeriesDetailPage.styled" - -type MetaRowProps = { - metaParts: string[] - instructorNames: string | null - departmentName: string | null - duration: string | null - term: string | null -} - -const MetaRow: React.FC = ({ - metaParts, - instructorNames, - departmentName, - duration, - term, -}) => { - const isMobile = useMediaQuery(theme.breakpoints.down("sm")) - - if (isMobile) { - if (!instructorNames && !departmentName && !duration && !term) return null - return ( - - {instructorNames && ( - - {instructorNames} - - )} - {departmentName &&
{departmentName}
} - {(duration || term) && ( - - {[duration, term].filter(Boolean).join(" · ")} - - )} -
- ) - } - - if (metaParts.length === 0) return null - return {metaParts.join(" · ")} -} - -export default MetaRow diff --git a/frontends/main/src/app-pages/VideoPlaylistCollectionPage/TopicChips.tsx b/frontends/main/src/app-pages/VideoPlaylistCollectionPage/TopicChips.tsx deleted file mode 100644 index 93f3dcb890..0000000000 --- a/frontends/main/src/app-pages/VideoPlaylistCollectionPage/TopicChips.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import React from "react" -import type { LearningResourceTopic } from "api/v1" -import * as Styled from "./VideoSeriesDetailPage.styled" - -type TopicChipsProps = { - topics: LearningResourceTopic[] -} - -const TopicChips: React.FC = ({ topics }) => { - if (topics.length === 0) return null - - return ( - <> - - - Video Series - - - {topics.map((topic) => ( - - {topic.name} - - ))} - - - ) -} - -export default TopicChips diff --git a/frontends/main/src/app-pages/VideoPlaylistCollectionPage/VideoSeriesDetailPage.styled.ts b/frontends/main/src/app-pages/VideoPlaylistCollectionPage/VideoSeriesDetailPage.styled.ts index 7cd30b54d9..459551f4aa 100644 --- a/frontends/main/src/app-pages/VideoPlaylistCollectionPage/VideoSeriesDetailPage.styled.ts +++ b/frontends/main/src/app-pages/VideoPlaylistCollectionPage/VideoSeriesDetailPage.styled.ts @@ -165,7 +165,7 @@ export const VideoTitle = styled.h1(({ theme }) => ({ ...theme.typography.h2, fontWeight: theme.typography.fontWeightBold, color: theme.custom.colors.black, - margin: "0 0 40px", + margin: "0 0 16px", "&:focus": { outline: "none" }, fontSize: "44px", fontStyle: "normal", @@ -173,7 +173,7 @@ export const VideoTitle = styled.h1(({ theme }) => ({ letterSpacing: "-0.88px", [theme.breakpoints.down("sm")]: { ...theme.typography.h3, - margin: "0 0 14px", + margin: "0 0 8px", letterSpacing: "inherit", }, })) @@ -297,8 +297,9 @@ export const MetaInstructorLine = styled.div(({ theme }) => ({ export const StyledDuration = styled.div(({ theme }) => ({ ...theme.typography.body2, color: theme.custom.colors.silverGrayDark, + margin: "0 0 40px", [theme.breakpoints.down("sm")]: { - marginTop: "4px", + margin: "0 0 16px", }, })) diff --git a/frontends/main/src/app-pages/VideoPlaylistCollectionPage/VideoSeriesDetailPage.test.tsx b/frontends/main/src/app-pages/VideoPlaylistCollectionPage/VideoSeriesDetailPage.test.tsx index 766bd1afaa..031c630b67 100644 --- a/frontends/main/src/app-pages/VideoPlaylistCollectionPage/VideoSeriesDetailPage.test.tsx +++ b/frontends/main/src/app-pages/VideoPlaylistCollectionPage/VideoSeriesDetailPage.test.tsx @@ -134,34 +134,6 @@ describe("VideoSeriesDetailPage", () => { name: "Introduction to Machine Learning", }) }) - - test("renders the institution label from the video department", async () => { - const video = makeVideo({ - departments: [ - factories.learningResources.department({ - department_id: "eecs", - name: "Electrical Engineering and Computer Science", - }), - ], - }) - renderPage({ video }) - - await screen.findByText("ELECTRICAL ENGINEERING AND COMPUTER SCIENCE") - }) - - test("renders the institution label from offered_by when no department", async () => { - const playlist = makePlaylist({ - offered_by: { - code: "ocw", - name: "MIT OpenCourseWare", - channel_url: null, - }, - }) - const video = makeVideo({ departments: [] }) - renderPage({ video, playlistId: playlist.id, playlistData: playlist }) - - await screen.findByText("MIT OPENCOURSEWARE") - }) }) describe("breadcrumbs", () => { @@ -391,42 +363,6 @@ describe("VideoSeriesDetailPage", () => { }) }) - describe("topic chips", () => { - test("renders topic chip links for each topic", async () => { - const video = makeVideo({ - topics: [ - { id: 1, name: "Machine Learning", parent: 10, channel_url: null }, - { id: 2, name: "Statistics", parent: 11, channel_url: null }, - ], - }) - renderPage({ video }) - - const mlChip = await screen.findByRole("link", { - name: "Machine Learning", - }) - const statsChip = screen.getByRole("link", { name: "Statistics" }) - expect(mlChip).toHaveAttribute("href", "/search?topic=Machine%20Learning") - expect(statsChip).toHaveAttribute("href", "/search?topic=Statistics") - }) - - test("renders the Video Series heading when topics are present", async () => { - const video = makeVideo({ - topics: [{ id: 1, name: "Robotics", parent: 5, channel_url: null }], - }) - renderPage({ video }) - - await screen.findByText("Video Series") - }) - - test("does not render the Video Series section when there are no topics", async () => { - const video = makeVideo({ topics: [] }) - renderPage({ video }) - - await screen.findByRole("heading", { name: video.title }) - expect(screen.queryByText("Video Series")).not.toBeInTheDocument() - }) - }) - describe("video player", () => { test("renders the video player when a streaming URL is present", async () => { const video = makeVideo({ diff --git a/frontends/main/src/app-pages/VideoPlaylistCollectionPage/VideoSeriesDetailPage.tsx b/frontends/main/src/app-pages/VideoPlaylistCollectionPage/VideoSeriesDetailPage.tsx index a38787342a..306d09224d 100644 --- a/frontends/main/src/app-pages/VideoPlaylistCollectionPage/VideoSeriesDetailPage.tsx +++ b/frontends/main/src/app-pages/VideoPlaylistCollectionPage/VideoSeriesDetailPage.tsx @@ -17,8 +17,6 @@ import { notFound } from "next/navigation" import { useSeriesNavigation } from "./useSeriesNavigation" import SeriesNavBar from "./SeriesNavBar" import UpNextSection from "./UpNextSection" -import MetaRow from "./MetaRow" -import TopicChips from "./TopicChips" import * as Styled from "./VideoSeriesDetailPage.styled" const VideoJsPlayer = dynamic( @@ -78,40 +76,14 @@ const VideoSeriesDetailPage: React.FC = ({ ? formatDurationClockTime(video.video.duration) : null - const topics = video?.topics ?? [] const playlistLabel = playlist?.title || "Video Collection" - // Meta: instructors, department, duration, term - const run = video?.runs?.[0] - const instructorNames = - run?.instructors - ?.map((i) => i.full_name) - .filter(Boolean) - .join(", ") ?? null - const departmentName = video?.departments?.[0]?.name ?? null - const term = - run?.semester && run?.year - ? `${run.semester} ${run.year}` - : run?.semester || (run?.year ? String(run.year) : null) - const metaParts = [instructorNames, departmentName, duration, term].filter( - Boolean, - ) as string[] - - const institutionLabel = - video?.departments?.[0]?.name?.toUpperCase() ?? - playlist?.offered_by?.name?.toUpperCase() ?? - null - const isLoading = videoLoading || (!!playlistId && playlistLoading) const videoTitleLabel = video?.title?.trim() || "Untitled video" const durationLabel = duration || "Unknown duration" - const topicNamesLabel = - topics - .map((t) => t.name) - .filter(Boolean) - .join(" · ") || "No topics listed" - const videoThumbnailAlt = `Video thumbnail for ${videoTitleLabel}. Duration: ${durationLabel}. Topics: ${topicNamesLabel}` + + const videoThumbnailAlt = `Video thumbnail for ${videoTitleLabel}. Duration: ${durationLabel}` const loadingStatusMessage = isLoading ? "Loading video details and player" : "Video details loaded" @@ -184,15 +156,6 @@ const VideoSeriesDetailPage: React.FC = ({ - {/* Institution / category label */} - {isLoading ? ( - - ) : institutionLabel ? ( - - {institutionLabel} - - ) : null} - {/* Video title */} {isLoading ? ( = ({ {video?.title} )} - + {duration && ( + {duration} + )} {/* Video player */} = ({ )} - {/* Meta row */} - {!isLoading && ( - - )} - {/* Description */} {!isLoading && video?.description && ( = ({ {!isLoading && !video?.description && ( - {videoTitleLabel}. Duration: {durationLabel}. Topics:{" "} - {topicNamesLabel}. + {videoTitleLabel}. Duration: {durationLabel}. )} - - {/* Topic chips */} - {!isLoading && } diff --git a/frontends/main/src/page-components/LearningResourceExpanded/CallToActionSection.test.tsx b/frontends/main/src/page-components/LearningResourceExpanded/CallToActionSection.test.tsx index 350af37be6..cceeea53e2 100644 --- a/frontends/main/src/page-components/LearningResourceExpanded/CallToActionSection.test.tsx +++ b/frontends/main/src/page-components/LearningResourceExpanded/CallToActionSection.test.tsx @@ -54,7 +54,7 @@ describe("CallToActionSection", () => { resourceType: ResourceTypeEnum.Video, platform: PlatformEnum.Youtube, resourceCategory: "Lecture Video", - expectedText: "Watch Video", + expectedText: "Learn More", }, { resourceType: ResourceTypeEnum.Video, @@ -66,7 +66,7 @@ describe("CallToActionSection", () => { resourceType: ResourceTypeEnum.VideoPlaylist, platform: PlatformEnum.Youtube, resourceCategory: "Video Playlist", - expectedText: "Watch Video", + expectedText: "Learn More", }, { resourceType: ResourceTypeEnum.Podcast, diff --git a/frontends/main/src/page-components/LearningResourceExpanded/CallToActionSection.tsx b/frontends/main/src/page-components/LearningResourceExpanded/CallToActionSection.tsx index e931fe7a11..1cdf7e6290 100644 --- a/frontends/main/src/page-components/LearningResourceExpanded/CallToActionSection.tsx +++ b/frontends/main/src/page-components/LearningResourceExpanded/CallToActionSection.tsx @@ -257,13 +257,12 @@ const getCallToActionText = (resource: LearningResource): string => { const listenToPodcast = "Listen to Podcast" const viewArticle = "View Article" const learnMore = "Learn More" - const watchVideos = "Watch Video" const callsToAction = { [ResourceTypeEnum.Course]: learnMore, [ResourceTypeEnum.Program]: learnMore, [ResourceTypeEnum.LearningPath]: learnMore, - [ResourceTypeEnum.Video]: watchVideos, - [ResourceTypeEnum.VideoPlaylist]: watchVideos, + [ResourceTypeEnum.Video]: learnMore, + [ResourceTypeEnum.VideoPlaylist]: learnMore, [ResourceTypeEnum.Podcast]: listenToPodcast, [ResourceTypeEnum.PodcastEpisode]: listenToPodcast, [ResourceTypeEnum.Document]: learnMore, From f0949213c6e0a88fc32c485abd838a27c4180da3 Mon Sep 17 00:00:00 2001 From: Chris Chudzicki Date: Mon, 4 May 2026 09:36:24 -0400 Subject: [PATCH 3/9] fix: assert run.title in DashboardCard heading test (#3282) #3253 was merged with stale CI from before #3269 changed getTitle to use run.title for CourseRunEnrollment. The new heading-level assertion still expected course.title. Co-authored-by: Claude Opus 4.7 (1M context) --- .../DashboardPage/CoursewareDisplay/DashboardCard.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/DashboardCard.test.tsx b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/DashboardCard.test.tsx index 33580a0ac3..cae4957486 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/DashboardCard.test.tsx +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/DashboardCard.test.tsx @@ -126,7 +126,7 @@ describe.each([ }) expect(courseLink).toHaveAttribute("href", coursewareUrl) expect( - within(card).getByRole("heading", { name: course.title, level: 3 }), + within(card).getByRole("heading", { name: courseRun.title, level: 3 }), ).toBeInTheDocument() }) From 3f998c26d09f1e8c5d339b38dd6ecefd6629eb4a Mon Sep 17 00:00:00 2001 From: Chris Chudzicki Date: Mon, 4 May 2026 11:03:57 -0400 Subject: [PATCH 4/9] set s-maxage via NEXT_CACHE_S_MAXAGE_SECONDS (#3280) * set s-maxage via NEXT_CACHE_S_MAXAGE_SECONDS * allow empty --- frontends/main/next.config.js | 10 ++++++---- frontends/main/validateEnv.js | 3 +++ 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/frontends/main/next.config.js b/frontends/main/next.config.js index 1f0b85bb55..e3dc571210 100644 --- a/frontends/main/next.config.js +++ b/frontends/main/next.config.js @@ -8,6 +8,10 @@ const NEXT_PUBLIC_OPTIMIZE_IMAGES = Boolean( ) const IS_LOCAL_DEV = process.env.NODE_ENV === "development" +const NEXT_CACHE_S_MAXAGE_SECONDS = + process.env.NEXT_CACHE_S_MAXAGE_SECONDS || "1800" +const PAGE_CACHE_CONTROL = `s-maxage=${NEXT_CACHE_S_MAXAGE_SECONDS}, stale-if-error=86400, stale-while-revalidate=86400` + const processFeatureFlags = () => { const featureFlagPrefix = process.env.NEXT_PUBLIC_POSTHOG_FEATURE_PREFIX || "FEATURE_" @@ -69,8 +73,7 @@ const nextConfig = { headers: [ { key: "Cache-Control", - value: - "s-maxage=1800, stale-if-error=86400, stale-while-revalidate=86400", + value: PAGE_CACHE_CONTROL, }, ], }, @@ -86,8 +89,7 @@ const nextConfig = { headers: [ { key: "Cache-Control", - value: - "s-maxage=1800, stale-if-error=86400, stale-while-revalidate=86400", + value: PAGE_CACHE_CONTROL, }, ], }, diff --git a/frontends/main/validateEnv.js b/frontends/main/validateEnv.js index e84d8fb45b..478e6da9ac 100644 --- a/frontends/main/validateEnv.js +++ b/frontends/main/validateEnv.js @@ -10,6 +10,9 @@ const yup = require("yup") const schema = yup.object().shape({ // Server-only env vars MITOL_NOINDEX: yup.string().oneOf(["true", "false"]), + NEXT_CACHE_S_MAXAGE_SECONDS: yup + .string() + .matches(/^\d+$/, { excludeEmptyString: true }), // Client or Server env vars NEXT_PUBLIC_APPZI_URL: yup.string(), NEXT_PUBLIC_ORIGIN: yup.string().required(), From ae359c563d565d6836f8d88141ff98a9e2dab1fb Mon Sep 17 00:00:00 2001 From: Carey P Gumaer Date: Mon, 4 May 2026 16:17:13 -0400 Subject: [PATCH 5/9] fix fallback when no language is selected (#3286) * fix fallback when no language is selected Co-authored-by: Copilot * remove dead code Co-authored-by: Copilot * add a test that ensures the cta is disabled if there are no enrollable runs --------- Co-authored-by: Copilot --- .../DashboardPage/ContractContent.test.tsx | 55 +++++++++++++++++++ .../CoursewareDisplay/languageOptions.test.ts | 36 ++++++++++++ .../CoursewareDisplay/languageOptions.ts | 23 +++++++- 3 files changed, 112 insertions(+), 2 deletions(-) diff --git a/frontends/main/src/app-pages/DashboardPage/ContractContent.test.tsx b/frontends/main/src/app-pages/DashboardPage/ContractContent.test.tsx index bb212cfbbf..a3bdf6cc5f 100644 --- a/frontends/main/src/app-pages/DashboardPage/ContractContent.test.tsx +++ b/frontends/main/src/app-pages/DashboardPage/ContractContent.test.tsx @@ -1677,6 +1677,61 @@ describe("ContractContent", () => { expect(screen.queryByText("Learning Language:")).not.toBeInTheDocument() }) + test("disables CTA for non-enrolled B2B course when no translations and no enrollable runs", async () => { + const { orgX, user, mitxOnlineUser } = setupOrgAndUser() + + const course = factories.courses.course() + const program = factories.programs.program({ + courses: [course.id], + }) + const contracts = createTestContracts(orgX.id, 1, [program.id]) + orgX.contracts = contracts + mitxOnlineUser.b2b_organizations[0].contracts = contracts + + const contractRun = factories.courses.courseRun({ + b2b_contract: contracts[0].id, + language: "en", + run_tag: undefined, + is_enrollable: false, + courseware_url: "https://openedx.example.com/unenrollable-run", + }) + + const courseWithoutTranslations = { + ...course, + courseruns: [contractRun], + language_options: [], + next_run_id: contractRun.id, + next_run: null, + } + + setupOrgDashboardMocks( + orgX, + user, + mitxOnlineUser, + [program], + [courseWithoutTranslations], + contracts, + ) + + setMockResponse.get(urls.enrollment.enrollmentsListV3(), []) + + renderWithProviders( + , + ) + + const programElement = await screen.findByTestId("org-program-root") + const card = await within(programElement).findByTestId( + "enrollment-card-desktop", + ) + + const coursewareButton = within(card).getByTestId("courseware-button") + expect(coursewareButton).toHaveTextContent("Start Module") + expect(coursewareButton).toBeDisabled() + }) + test("displays correct run URL when user is enrolled in one of multiple runs", async () => { const { orgX, user, mitxOnlineUser } = setupOrgAndUser() diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/languageOptions.test.ts b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/languageOptions.test.ts index 8b6cb42201..da6805fd6f 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/languageOptions.test.ts +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/languageOptions.test.ts @@ -392,6 +392,42 @@ describe("languageOptions", () => { expect(selectedEnrollment?.run.courseware_id).toBe(spanishRun.courseware_id) }) + test("matches enrollment when course has no language options", () => { + const run = factories.courses.courseRun({ + id: 7001, + title: "Enrolled Course", + courseware_id: "cw-enrolled-7001", + courseware_url: "https://example.com/cw-enrolled-7001", + is_enrollable: true, + }) + + const course = factories.courses.course({ + courseruns: [run], + next_run_id: run.id, + language_options: [], + }) + + const selectedRun = getCourseRunForSelectedLanguage(course, "") + const enrollment = factories.enrollment.courseEnrollment({ + run: { + id: run.id, + course: { id: course.id, title: course.title }, + title: run.title, + courseware_id: run.courseware_id, + courseware_url: run.courseware_url, + }, + }) + + const selectedEnrollment = getEnrollmentForSelectedLanguage( + [enrollment], + null, + selectedRun, + ) + + expect(selectedRun?.id).toBe(run.id) + expect(selectedEnrollment?.run.id).toBe(run.id) + }) + test("adapts V3 enrollment run into V2-shaped run context", () => { const templateRun = factories.courses.courseRun({ id: 500, diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/languageOptions.ts b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/languageOptions.ts index b5f2670640..f9b72c3091 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/languageOptions.ts +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/languageOptions.ts @@ -235,7 +235,11 @@ const getCourseRunForSelectedLanguage = ( ): CourseRunV2 | null => { const languageOption = getSelectedLanguageOption(course, selectedLanguageKey) if (!languageOption) { - return null + return ( + getBestRun(course, { enrollableOnly: true }) ?? + course.courseruns[0] ?? + null + ) } const matchingRuns = getRunsForLanguageOption(course, languageOption) @@ -267,7 +271,22 @@ const getEnrollmentForSelectedLanguage = ( selectedRun: CourseRunV2 | null, ): CourseRunEnrollmentV3 | null => { if (!selectedLanguageOption) { - return null + if (!selectedRun) { + return null + } + + return ( + enrollments.find((enrollment) => { + if (!enrollment.run) { + return false + } + + return ( + enrollment.run.id === selectedRun.id || + enrollment.run.courseware_id === selectedRun.courseware_id + ) + }) ?? null + ) } return ( From 67039132101464c3db0ba47810908f50a4111f7f Mon Sep 17 00:00:00 2001 From: Chris Chudzicki Date: Mon, 4 May 2026 16:25:08 -0400 Subject: [PATCH 6/9] filter server-side, remove video count (#3287) --- .../api/src/hooks/learningResources/index.ts | 11 ---------- .../src/hooks/learningResources/queries.ts | 16 ++++++++------ .../VideoCollection.tsx | 22 +------------------ .../VideoPlaylistCollectionPage.tsx | 13 +++++------ .../ResourceCarousel/ResourceCarousel.tsx | 6 ++--- 5 files changed, 19 insertions(+), 49 deletions(-) diff --git a/frontends/api/src/hooks/learningResources/index.ts b/frontends/api/src/hooks/learningResources/index.ts index fe697779a8..989c023895 100644 --- a/frontends/api/src/hooks/learningResources/index.ts +++ b/frontends/api/src/hooks/learningResources/index.ts @@ -193,16 +193,6 @@ const useSimilarLearningResources = ( }) } -const useVectorSimilarLearningResources = ( - id: number, - opts?: { enabled?: boolean }, -) => { - return useQuery({ - ...learningResourceQueries.vectorSimilar(id), - ...opts, - }) -} - const useInfiniteLearningResourceItems = ( id: number, params: Omit, @@ -229,7 +219,6 @@ export { usePlatformsList, useSchoolsList, useSimilarLearningResources, - useVectorSimilarLearningResources, useInfiniteLearningResourceItems, learningResourceQueries, offerorQueries, diff --git a/frontends/api/src/hooks/learningResources/queries.ts b/frontends/api/src/hooks/learningResources/queries.ts index d6e38395d2..505da571ea 100644 --- a/frontends/api/src/hooks/learningResources/queries.ts +++ b/frontends/api/src/hooks/learningResources/queries.ts @@ -22,6 +22,7 @@ import type { LearningResourcesApiLearningResourcesSummaryListRequest as LearningResourcesSummaryListRequest, PaginatedLearningResourceRelationshipList, VideoPlaylistResource, + LearningResourcesApiLearningResourcesVectorSimilarListRequest, } from "../../generated/v1" import type { VectorLearningResourcesSearchApiVectorLearningResourcesSearchRetrieveRequest as VectorLearningResourcesSearchRetrieveRequest } from "../../generated/v0" import { infiniteQueryOptions, queryOptions } from "@tanstack/react-query" @@ -54,10 +55,9 @@ const learningResourceKeys = { detailsRoot: () => [...learningResourceKeys.root, "detail"], detail: (id: number) => [...learningResourceKeys.detailsRoot(), id], similar: (id: number) => [...learningResourceKeys.detail(id), "similar"], - vectorSimilar: (id: number) => [ - ...learningResourceKeys.detail(id), - "vector_similar", - ], + vectorSimilar: ( + params: LearningResourcesApiLearningResourcesVectorSimilarListRequest, + ) => [...learningResourceKeys.detail(params.id), "vector_similar", params], itemsRoot: (id: number) => [...learningResourceKeys.detail(id), "items"], items: (id: number, params: ItemsListRequest) => [ ...learningResourceKeys.itemsRoot(id), @@ -174,12 +174,14 @@ const learningResourceQueries = { .learningResourcesSimilarList({ id }) .then((res) => res.data), }), - vectorSimilar: (id: number) => + vectorSimilar: ( + params: LearningResourcesApiLearningResourcesVectorSimilarListRequest, + ) => queryOptions({ - queryKey: learningResourceKeys.vectorSimilar(id), + queryKey: learningResourceKeys.vectorSimilar(params), queryFn: () => learningResourcesApi - .learningResourcesVectorSimilarList({ id }) + .learningResourcesVectorSimilarList(params) .then((res) => res.data), }), list: (params: LearningResourcesListRequest) => diff --git a/frontends/main/src/app-pages/VideoPlaylistCollectionPage/VideoCollection.tsx b/frontends/main/src/app-pages/VideoPlaylistCollectionPage/VideoCollection.tsx index b1a768ab98..db8c558a73 100644 --- a/frontends/main/src/app-pages/VideoPlaylistCollectionPage/VideoCollection.tsx +++ b/frontends/main/src/app-pages/VideoPlaylistCollectionPage/VideoCollection.tsx @@ -1,5 +1,5 @@ import React from "react" -import { Typography, styled, theme } from "ol-components" +import { styled } from "ol-components" import VideoContainer from "./VideoContainer" import type { VideoResource } from "api/v1" import { VideoCard, VideoCardSkeleton } from "./VideoCard" @@ -18,22 +18,6 @@ const StyledContainer = styled(VideoContainer)(({ theme }) => ({ borderTop: `1px solid ${theme.custom.colors.lightGray2}`, })) -const CollectionHeader = styled.div(({ theme }) => ({ - display: "flex", - justifyContent: "space-between", - alignItems: "center", - margin: "32px 0 8px 0", - [theme.breakpoints.down("sm")]: { - margin: "24px 0 0 0", - }, -})) - -const CollectionTitle = styled(Typography)({ - ...theme.typography.body1, - fontWeight: theme.typography.fontWeightMedium, - color: theme.custom.colors.black, -}) - const VideoCardList = styled.div(({ theme }) => ({ display: "flex", flexDirection: "column", @@ -61,10 +45,6 @@ const VideoCollection: React.FC = ({ return ( - - {videos.length} Videos - - {isLoading ? Array.from({ length: 4 }).map((_, i) => ( diff --git a/frontends/main/src/app-pages/VideoPlaylistCollectionPage/VideoPlaylistCollectionPage.tsx b/frontends/main/src/app-pages/VideoPlaylistCollectionPage/VideoPlaylistCollectionPage.tsx index 7855166de1..7329da8744 100644 --- a/frontends/main/src/app-pages/VideoPlaylistCollectionPage/VideoPlaylistCollectionPage.tsx +++ b/frontends/main/src/app-pages/VideoPlaylistCollectionPage/VideoPlaylistCollectionPage.tsx @@ -69,11 +69,11 @@ const VideoPlaylistCollectionPage: React.FC< ) const { data: similarData, isLoading: similarLoading } = useQuery({ - ...learningResourceQueries.vectorSimilar(playlistId), - select: (data) => - data.filter( - (resource) => resource.resource_type === ResourceTypeEnum.VideoPlaylist, - ), + ...learningResourceQueries.vectorSimilar({ + id: playlistId, + limit: 6, + resource_type: [ResourceTypeEnum.VideoPlaylist], + }), }) if (!showVideoPlaylistPage) { @@ -88,7 +88,6 @@ const VideoPlaylistCollectionPage: React.FC< (item): item is VideoResource => item.resource_type === VideoResourceResourceTypeEnum.Video, ) - const collectionVideos = videos.slice(1) const playlistType = isOcwPlaylist(playlist) const totalVideos = videos.length @@ -128,7 +127,7 @@ const VideoPlaylistCollectionPage: React.FC< ) : null} {!playlistType && ( diff --git a/frontends/main/src/page-components/ResourceCarousel/ResourceCarousel.tsx b/frontends/main/src/page-components/ResourceCarousel/ResourceCarousel.tsx index 22e7d2c075..3d08a8ee58 100644 --- a/frontends/main/src/page-components/ResourceCarousel/ResourceCarousel.tsx +++ b/frontends/main/src/page-components/ResourceCarousel/ResourceCarousel.tsx @@ -200,9 +200,9 @@ const getTabQuery = (tab: TabConfig): CarouselQuery => { tab.data.params.id, ) as CarouselQuery case "lr_vector_similar": - return learningResourceQueries.vectorSimilar( - tab.data.params.id, - ) as CarouselQuery + return learningResourceQueries.vectorSimilar({ + id: tab.data.params.id, + }) as CarouselQuery } } From d5b5f9117ba7a7aa58fd14d3a243af7a584343f6 Mon Sep 17 00:00:00 2001 From: Chris Chudzicki Date: Mon, 4 May 2026 19:52:31 -0400 Subject: [PATCH 7/9] prefer existing B2B enrollment over next_run_id when picking same-language run (#3291) When a contract has both an old and a new run of the same course (same language) and the user is enrolled in the older one, the dashboard was hiding their enrollment because language resolution defaulted to `course.next_run_id` (the newer, unenrolled run). Add `selectBestContractEnrollmentForLanguage` to find the user's best existing enrollment for the selected language (by language_options id / courseware_id, regardless of `is_enrollable`), and call it from both B2B paths in ContractContent before falling back to the next/best run. Co-authored-by: Claude Opus 4.7 (1M context) --- .../DashboardPage/ContractContent.tsx | 61 ++++--- .../CoursewareDisplay/languageOptions.test.ts | 170 ++++++++++++++++++ .../CoursewareDisplay/languageOptions.ts | 60 +++++++ 3 files changed, 265 insertions(+), 26 deletions(-) diff --git a/frontends/main/src/app-pages/DashboardPage/ContractContent.tsx b/frontends/main/src/app-pages/DashboardPage/ContractContent.tsx index 61026e0a2a..02ad684c39 100644 --- a/frontends/main/src/app-pages/DashboardPage/ContractContent.tsx +++ b/frontends/main/src/app-pages/DashboardPage/ContractContent.tsx @@ -37,9 +37,9 @@ import { ResourceType, getKey } from "./CoursewareDisplay/helpers" import { getCourseRunForSelectedLanguage, getDistinctLanguageOptions, - getEnrollmentForSelectedLanguage, getResolvedRunForSelectedLanguage, getSelectedLanguageOption, + selectBestContractEnrollmentForLanguage, } from "./CoursewareDisplay/languageOptions" import UnstyledRawHTML from "@/components/UnstyledRawHTML/UnstyledRawHTML" @@ -350,29 +350,34 @@ const OrgProgramCollectionDisplay: React.FC<{ /> ))} {rawCourses.map((course) => { - const selectedLanguageOption = getSelectedLanguageOption( - course, - selectedLanguageKey, - ) - const selectedRun = getCourseRunForSelectedLanguage( - course, - selectedLanguageKey, - ) // Filter enrollments to only those matching this contract const contractEnrollments = enrollments?.filter( (enrollment) => enrollment.b2b_contract_id === contract.id, ) ?? [] - const selectedLanguageEnrollment = getEnrollmentForSelectedLanguage( - contractEnrollments, - selectedLanguageOption, - selectedRun, + // Prefer the user's existing enrollment for the selected language + // over the next/best run, so older-run enrollments stay visible + // when the contract surfaces a newer run. + const selectedLanguageEnrollment = + selectBestContractEnrollmentForLanguage( + course, + contractEnrollments, + selectedLanguageKey, + ) + const selectedLanguageOption = getSelectedLanguageOption( + course, + selectedLanguageKey, ) + const selectedRun = selectedLanguageEnrollment + ? ((course.courseruns ?? []).find( + (r) => r.id === selectedLanguageEnrollment.run.id, + ) ?? null) + : getCourseRunForSelectedLanguage(course, selectedLanguageKey) const resolvedRun = getResolvedRunForSelectedLanguage( course, selectedLanguageOption, selectedRun, - selectedLanguageEnrollment ?? null, + selectedLanguageEnrollment, contract.id, ) return ( @@ -473,30 +478,34 @@ const OrgProgramDisplay: React.FC<{ {programLoading || coursesQuery.isLoading ? skeleton : courses.map((course) => { - const selectedLanguageOption = getSelectedLanguageOption( - course, - selectedLanguageKey, - ) - const selectedRun = getCourseRunForSelectedLanguage( - course, - selectedLanguageKey, - ) // Filter enrollments to only those matching this contract const contractEnrollments = courseRunEnrollments?.filter( (enrollment) => enrollment.b2b_contract_id === contract?.id, ) ?? [] + // Prefer the user's existing enrollment for the selected + // language over the next/best run, so older-run enrollments + // stay visible when the contract surfaces a newer run. const selectedLanguageEnrollment = - getEnrollmentForSelectedLanguage( + selectBestContractEnrollmentForLanguage( + course, contractEnrollments, - selectedLanguageOption, - selectedRun, + selectedLanguageKey, ) + const selectedLanguageOption = getSelectedLanguageOption( + course, + selectedLanguageKey, + ) + const selectedRun = selectedLanguageEnrollment + ? ((course.courseruns ?? []).find( + (r) => r.id === selectedLanguageEnrollment.run.id, + ) ?? null) + : getCourseRunForSelectedLanguage(course, selectedLanguageKey) const resolvedRun = getResolvedRunForSelectedLanguage( course, selectedLanguageOption, selectedRun, - selectedLanguageEnrollment ?? null, + selectedLanguageEnrollment, contract?.id, ) diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/languageOptions.test.ts b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/languageOptions.test.ts index da6805fd6f..abff0b07af 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/languageOptions.test.ts +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/languageOptions.test.ts @@ -10,6 +10,7 @@ import { getLanguageOptionKey, getResolvedRunForSelectedLanguage, getSelectedLanguageOption, + selectBestContractEnrollmentForLanguage, } from "./languageOptions" type LanguageOptionWithEnrollability = CourseRunLanguageOption & { @@ -692,4 +693,173 @@ describe("languageOptions", () => { ), ).toEqual(selectedRun) }) + + describe("selectBestContractEnrollmentForLanguage", () => { + test("surfaces older-run enrollment when next_run_id points at unenrolled newer run", () => { + const oldRun = factories.courses.courseRun({ + id: 544, + courseware_id: "course-v1:UAI+UAI.13+2025", + courseware_url: "https://example.com/2025", + is_enrollable: false, + }) + const newRun = factories.courses.courseRun({ + id: 2325, + courseware_id: "course-v1:UAI+UAI.13+2026", + courseware_url: "https://example.com/2026", + is_enrollable: true, + }) + const course = factories.courses.course({ + courseruns: [oldRun, newRun], + next_run_id: newRun.id, + language_options: [ + { + id: oldRun.id, + courseware_id: oldRun.courseware_id, + courseware_url: oldRun.courseware_url ?? "", + language: "en", + title: oldRun.title, + run_tag: oldRun.run_tag, + }, + { + id: newRun.id, + courseware_id: newRun.courseware_id, + courseware_url: newRun.courseware_url ?? "", + language: "en", + title: newRun.title, + run_tag: newRun.run_tag, + }, + ], + }) + + const oldEnrollment = factories.enrollment.courseEnrollment({ + run: { + id: oldRun.id, + course: { id: course.id, title: course.title }, + title: oldRun.title, + courseware_id: oldRun.courseware_id, + courseware_url: oldRun.courseware_url, + }, + }) + + const result = selectBestContractEnrollmentForLanguage( + course, + [oldEnrollment], + "", + ) + + expect(result?.run.id).toBe(oldRun.id) + }) + + test("returns null when user has no enrollment for the selected language", () => { + const englishRun = factories.courses.courseRun({ + id: 1, + courseware_id: "cw-en", + is_enrollable: true, + }) + const spanishRun = factories.courses.courseRun({ + id: 2, + courseware_id: "cw-es", + is_enrollable: true, + }) + const course = factories.courses.course({ + courseruns: [englishRun, spanishRun], + next_run_id: englishRun.id, + language_options: [ + { + id: englishRun.id, + courseware_id: englishRun.courseware_id, + courseware_url: englishRun.courseware_url ?? "", + language: "en", + title: englishRun.title, + run_tag: englishRun.run_tag, + }, + { + id: spanishRun.id, + courseware_id: spanishRun.courseware_id, + courseware_url: spanishRun.courseware_url ?? "", + language: "es", + title: spanishRun.title, + run_tag: spanishRun.run_tag, + }, + ], + }) + + const spanishEnrollment = factories.enrollment.courseEnrollment({ + run: { + id: spanishRun.id, + course: { id: course.id, title: course.title }, + title: spanishRun.title, + courseware_id: spanishRun.courseware_id, + }, + }) + + expect( + selectBestContractEnrollmentForLanguage( + course, + [spanishEnrollment], + "language:en", + ), + ).toBeNull() + }) + + test("prefers higher-graded enrollment when multiple match the language", () => { + const olderRun = factories.courses.courseRun({ + id: 10, + courseware_id: "cw-old", + }) + const newerRun = factories.courses.courseRun({ + id: 20, + courseware_id: "cw-new", + }) + const course = factories.courses.course({ + courseruns: [olderRun, newerRun], + next_run_id: newerRun.id, + language_options: [ + { + id: olderRun.id, + courseware_id: olderRun.courseware_id, + courseware_url: olderRun.courseware_url ?? "", + language: "en", + title: olderRun.title, + run_tag: olderRun.run_tag, + }, + { + id: newerRun.id, + courseware_id: newerRun.courseware_id, + courseware_url: newerRun.courseware_url ?? "", + language: "en", + title: newerRun.title, + run_tag: newerRun.run_tag, + }, + ], + }) + + const newerEnrollment = factories.enrollment.courseEnrollment({ + run: { + id: newerRun.id, + course: { id: course.id, title: course.title }, + title: newerRun.title, + courseware_id: newerRun.courseware_id, + }, + grades: [factories.enrollment.grade({ grade: 0.4, passed: false })], + }) + const olderEnrollment = factories.enrollment.courseEnrollment({ + run: { + id: olderRun.id, + course: { id: course.id, title: course.title }, + title: olderRun.title, + courseware_id: olderRun.courseware_id, + }, + grades: [factories.enrollment.grade({ grade: 0.95, passed: true })], + }) + + const result = selectBestContractEnrollmentForLanguage( + course, + [newerEnrollment, olderEnrollment], + "language:en", + ) + + expect(result?.run.id).toBe(olderRun.id) + }) + }) }) diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/languageOptions.ts b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/languageOptions.ts index f9b72c3091..0f0b197d4d 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/languageOptions.ts +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/languageOptions.ts @@ -304,6 +304,65 @@ const getEnrollmentForSelectedLanguage = ( ) } +/** + * Among contract-scoped enrollments, pick the user's best existing enrollment + * whose run is for the selected language on this course. Returns null when the + * user has no enrollment matching the language. + * + * Run language is determined via `course.language_options` (matching by id or + * courseware_id), independent of `is_enrollable` — we want to surface + * enrollments in older/closed runs too. + * + * Tiebreak across multiple matches: certificate > highest grade > first. + */ +const selectBestContractEnrollmentForLanguage = ( + course: CourseWithCourseRunsSerializerV2, + enrollments: CourseRunEnrollmentV3[], + selectedLanguageKey: string, +): CourseRunEnrollmentV3 | null => { + const resolvedKey = + selectedLanguageKey || getDefaultLanguageOptionKey(course) || "" + if (!resolvedKey) { + return null + } + + const matchingOptions = (course.language_options ?? []).filter( + (option) => getLanguageOptionKey(option) === resolvedKey, + ) + if (matchingOptions.length === 0) { + return null + } + + const optionRunIds = new Set() + const optionCoursewareIds = new Set() + matchingOptions.forEach((option) => { + optionRunIds.add(option.id) + if (option.courseware_id) { + optionCoursewareIds.add(option.courseware_id) + } + }) + + const matching = enrollments.filter( + (enrollment) => + optionRunIds.has(enrollment.run.id) || + optionCoursewareIds.has(enrollment.run.courseware_id), + ) + + if (matching.length === 0) { + return null + } + + return matching.reduce((best, current) => { + const bestHasCert = !!best.certificate?.uuid + const currentHasCert = !!current.certificate?.uuid + if (currentHasCert && !bestHasCert) return current + if (bestHasCert && !currentHasCert) return best + const bestGrade = Math.max(0, ...best.grades.map((g) => g.grade ?? 0)) + const currentGrade = Math.max(0, ...current.grades.map((g) => g.grade ?? 0)) + return currentGrade > bestGrade ? current : best + }, matching[0]) +} + const getResolvedRunForSelectedLanguage = ( course: CourseWithCourseRunsSerializerV2, selectedLanguageOption: CourseRunLanguageOption | null, @@ -402,6 +461,7 @@ const getResolvedRunForSelectedLanguage = ( export { getLanguageCodeFromOptionKey, getLanguageOptionKey, + selectBestContractEnrollmentForLanguage, getDistinctLanguageOptions, getSelectedLanguageOption, getCourseRunForSelectedLanguage, From 43961b3f8191dbd09fe3cc70de500ea35bfcd9b0 Mon Sep 17 00:00:00 2001 From: Carey P Gumaer Date: Mon, 4 May 2026 22:37:00 -0400 Subject: [PATCH 8/9] fix b2b dashboard empty language code (#3293) * add fallback to handle no matching language case Co-authored-by: Copilot * fall back to best enrollment selection if no language is present in any runs Co-authored-by: Copilot * handle missing language on course 1, set language on course 2 Co-authored-by: Copilot --------- Co-authored-by: Copilot --- .../CoursewareDisplay/languageOptions.test.ts | 166 ++++++++++++++++++ .../CoursewareDisplay/languageOptions.ts | 23 ++- 2 files changed, 187 insertions(+), 2 deletions(-) diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/languageOptions.test.ts b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/languageOptions.test.ts index abff0b07af..d0b9f247df 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/languageOptions.test.ts +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/languageOptions.test.ts @@ -802,6 +802,172 @@ describe("languageOptions", () => { ).toBeNull() }) + test("falls back to legacy best enrollment when all language metadata is blank", () => { + const run1 = factories.courses.courseRun({ + id: 701, + courseware_id: "cw-run-1", + is_enrollable: false, + }) + const run2 = factories.courses.courseRun({ + id: 702, + courseware_id: "cw-run-2", + is_enrollable: true, + }) + const course = factories.courses.course({ + courseruns: [run1, run2], + next_run_id: run2.id, + language_options: [ + { + id: run1.id, + courseware_id: run1.courseware_id, + courseware_url: run1.courseware_url ?? "", + language: "", + title: run1.title, + run_tag: run1.run_tag, + }, + { + id: run2.id, + courseware_id: run2.courseware_id, + courseware_url: run2.courseware_url ?? "", + language: "", + title: run2.title, + run_tag: run2.run_tag, + }, + ], + }) + + const enrolledRun = factories.enrollment.courseEnrollment({ + run: { + id: run1.id, + course: { id: course.id, title: course.title }, + title: run1.title, + courseware_id: run1.courseware_id, + courseware_url: run1.courseware_url, + }, + }) + + const result = selectBestContractEnrollmentForLanguage( + course, + [enrolledRun], + "", + ) + + expect(result?.run.id).toBe(run1.id) + }) + + test("falls back to legacy best enrollment when selected language is en but this course has only blank language metadata", () => { + const run1 = factories.courses.courseRun({ + id: 801, + courseware_id: "cw-blank-1", + is_enrollable: false, + }) + const run2 = factories.courses.courseRun({ + id: 802, + courseware_id: "cw-blank-2", + is_enrollable: true, + }) + + const course = factories.courses.course({ + courseruns: [run1, run2], + next_run_id: run2.id, + language_options: [ + { + id: run1.id, + courseware_id: run1.courseware_id, + courseware_url: run1.courseware_url ?? "", + language: "", + title: run1.title, + run_tag: run1.run_tag, + }, + { + id: run2.id, + courseware_id: run2.courseware_id, + courseware_url: run2.courseware_url ?? "", + language: "", + title: run2.title, + run_tag: run2.run_tag, + }, + ], + }) + + const oldEnrollment = factories.enrollment.courseEnrollment({ + run: { + id: run1.id, + course: { id: course.id, title: course.title }, + title: run1.title, + courseware_id: run1.courseware_id, + courseware_url: run1.courseware_url, + }, + }) + + const result = selectBestContractEnrollmentForLanguage( + course, + [oldEnrollment], + "language:en", + ) + + expect(result?.run.id).toBe(run1.id) + }) + + test("falls back to legacy best enrollment on single-language courses when language-option run matching fails", () => { + const languageRunA = factories.courses.courseRun({ + id: 101, + courseware_id: "cw-en-a", + is_enrollable: true, + }) + const languageRunB = factories.courses.courseRun({ + id: 102, + courseware_id: "cw-en-b", + is_enrollable: true, + }) + const enrolledRun = factories.courses.courseRun({ + id: 103, + courseware_id: "cw-enrolled", + is_enrollable: false, + }) + + const course = factories.courses.course({ + courseruns: [languageRunA, languageRunB, enrolledRun], + next_run_id: languageRunA.id, + language_options: [ + { + id: languageRunA.id, + courseware_id: languageRunA.courseware_id, + courseware_url: languageRunA.courseware_url ?? "", + language: "en", + title: languageRunA.title, + run_tag: languageRunA.run_tag, + }, + { + id: languageRunB.id, + courseware_id: languageRunB.courseware_id, + courseware_url: languageRunB.courseware_url ?? "", + language: "en", + title: languageRunB.title, + run_tag: languageRunB.run_tag, + }, + ], + }) + + const enrolledRunEnrollment = factories.enrollment.courseEnrollment({ + run: { + id: enrolledRun.id, + course: { id: course.id, title: course.title }, + title: enrolledRun.title, + courseware_id: enrolledRun.courseware_id, + courseware_url: enrolledRun.courseware_url, + }, + }) + + const result = selectBestContractEnrollmentForLanguage( + course, + [enrolledRunEnrollment], + "", + ) + + expect(result?.run.id).toBe(enrolledRun.id) + }) + test("prefers higher-graded enrollment when multiple match the language", () => { const olderRun = factories.courses.courseRun({ id: 10, diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/languageOptions.ts b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/languageOptions.ts index 0f0b197d4d..efebedbda2 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/languageOptions.ts +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/languageOptions.ts @@ -5,7 +5,7 @@ import type { CourseRunV2, CourseWithCourseRunsSerializerV2, } from "@mitodl/mitxonline-api-axios/v2" -import { getBestRun } from "./helpers" +import { getBestRun, selectBestEnrollment } from "./helpers" const LANGUAGE_CODE_TO_NATIVE_NAME: Record = { ar: "العربية", @@ -114,6 +114,16 @@ const getEnrollableLanguageOptions = ( }) } +const getUsableLanguageKeys = ( + course: CourseWithCourseRunsSerializerV2, +): Set => { + return new Set( + (course.language_options ?? []) + .map((option) => getLanguageOptionKey(option)) + .filter((key) => Boolean(key)), + ) +} + const getDefaultLanguageOptionKey = ( course: CourseWithCourseRunsSerializerV2, ): string | null => { @@ -322,14 +332,18 @@ const selectBestContractEnrollmentForLanguage = ( ): CourseRunEnrollmentV3 | null => { const resolvedKey = selectedLanguageKey || getDefaultLanguageOptionKey(course) || "" + const usableLanguageKeys = getUsableLanguageKeys(course) if (!resolvedKey) { - return null + return selectBestEnrollment(course, enrollments) } const matchingOptions = (course.language_options ?? []).filter( (option) => getLanguageOptionKey(option) === resolvedKey, ) if (matchingOptions.length === 0) { + if (usableLanguageKeys.size === 0) { + return selectBestEnrollment(course, enrollments) + } return null } @@ -349,6 +363,11 @@ const selectBestContractEnrollmentForLanguage = ( ) if (matching.length === 0) { + // If this course effectively has a single usable language (or none), + // preserve legacy behavior by using best enrollment association. + if (usableLanguageKeys.size <= 1) { + return selectBestEnrollment(course, enrollments) + } return null } From 347c27a66b23b544d2df61b0a23d8b5b830cea87 Mon Sep 17 00:00:00 2001 From: Doof Date: Tue, 5 May 2026 02:37:32 +0000 Subject: [PATCH 9/9] Release 0.66.2 --- RELEASE.rst | 12 ++++++++++++ main/settings.py | 2 +- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/RELEASE.rst b/RELEASE.rst index dae25b1c31..de51b7d533 100644 --- a/RELEASE.rst +++ b/RELEASE.rst @@ -1,6 +1,18 @@ Release Notes ============= +Version 0.66.2 +-------------- + +- fix b2b dashboard empty language code (#3293) +- prefer existing B2B enrollment over next_run_id when picking same-language run (#3291) +- filter server-side, remove video count (#3287) +- fix fallback when no language is selected (#3286) +- set s-maxage via NEXT_CACHE_S_MAXAGE_SECONDS (#3280) +- fix: assert run.title in DashboardCard heading test (#3282) +- fix: adjust duration position and remove institution label from the video series page and change the title of video and playlist on drawer (#3270) +- qdrant: Automatically compute optimizer settings (#3273) + Version 0.65.6 (Released May 04, 2026) -------------- diff --git a/main/settings.py b/main/settings.py index 00ec603259..86911d0d64 100644 --- a/main/settings.py +++ b/main/settings.py @@ -35,7 +35,7 @@ from main.settings_pluggy import * # noqa: F403 from openapi.settings_spectacular import open_spectacular_settings -VERSION = "0.65.6" +VERSION = "0.66.2" log = logging.getLogger()