From 605e93f8bcffb5032f94757f1d3af6295e25e4d6 Mon Sep 17 00:00:00 2001 From: Chris Chudzicki Date: Wed, 25 Mar 2026 08:03:32 -0400 Subject: [PATCH 1/7] update the client again! (#3094) --- frontends/api/package.json | 2 +- .../src/mitxonline/test-utils/factories/courses.ts | 1 + .../api/src/mitxonline/test-utils/factories/pages.ts | 1 + frontends/main/package.json | 2 +- yarn.lock | 12 ++++++------ 5 files changed, 10 insertions(+), 8 deletions(-) diff --git a/frontends/api/package.json b/frontends/api/package.json index f7d2d54b24..e7f2764d51 100644 --- a/frontends/api/package.json +++ b/frontends/api/package.json @@ -29,7 +29,7 @@ "ol-test-utilities": "0.0.0" }, "dependencies": { - "@mitodl/mitxonline-api-axios": "^2026.3.23", + "@mitodl/mitxonline-api-axios": "^2026.3.24", "@tanstack/react-query": "^5.66.0", "axios": "^1.12.2", "tiny-invariant": "^1.3.3" diff --git a/frontends/api/src/mitxonline/test-utils/factories/courses.ts b/frontends/api/src/mitxonline/test-utils/factories/courses.ts index ac58dca3b3..cd06973f2c 100644 --- a/frontends/api/src/mitxonline/test-utils/factories/courses.ts +++ b/frontends/api/src/mitxonline/test-utils/factories/courses.ts @@ -154,6 +154,7 @@ const course: PartialFactory = ( name: faker.company.name(), }, ], + certificate_available: faker.datatype.boolean(), page: { feature_image_src: faker.image.avatar(), page_url: faker.internet.url(), diff --git a/frontends/api/src/mitxonline/test-utils/factories/pages.ts b/frontends/api/src/mitxonline/test-utils/factories/pages.ts index 6e614df19c..6cba1acc51 100644 --- a/frontends/api/src/mitxonline/test-utils/factories/pages.ts +++ b/frontends/api/src/mitxonline/test-utils/factories/pages.ts @@ -65,6 +65,7 @@ const v2Course: PartialFactory = (overrides = {}) => { id: uniqueCourseId.enforce(() => faker.number.int()), title: faker.lorem.words(3), readable_id: faker.lorem.slug(), + certificate_available: faker.datatype.boolean(), page: { feature_image_src: faker.image.avatar(), page_url: faker.internet.url(), diff --git a/frontends/main/package.json b/frontends/main/package.json index ed53621548..6c910e0f1c 100644 --- a/frontends/main/package.json +++ b/frontends/main/package.json @@ -14,7 +14,7 @@ "@emotion/styled": "^11.11.0", "@floating-ui/react": "^0.27.16", "@mitodl/course-search-utils": "^3.5.2", - "@mitodl/mitxonline-api-axios": "^2026.3.23", + "@mitodl/mitxonline-api-axios": "^2026.3.24", "@mitodl/smoot-design": "^6.24.0", "@mui/material": "^6.4.5", "@mui/material-nextjs": "^6.4.3", diff --git a/yarn.lock b/yarn.lock index 01ce64ad51..1ba46b873d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3514,13 +3514,13 @@ __metadata: languageName: node linkType: hard -"@mitodl/mitxonline-api-axios@npm:^2026.3.23": - version: 2026.3.23 - resolution: "@mitodl/mitxonline-api-axios@npm:2026.3.23" +"@mitodl/mitxonline-api-axios@npm:^2026.3.24": + version: 2026.3.24 + resolution: "@mitodl/mitxonline-api-axios@npm:2026.3.24" dependencies: "@types/node": "npm:^20.11.19" axios: "npm:^1.6.5" - checksum: 10/4d23b0753e64de96c76b7f505a22e1d717915806e0b98579225046e1db5ca7433d6c912e2e3bd0bda6688f7e1bd8ae1a1d39bc7fde0130abd9999908fc4cf92e + checksum: 10/aa3c320515a8436df8c16164dd37d96ee0a9900d2e6575e9d151326a5398be6e20dfadb899bfe0604551080da64cefa1158f63d520f333325dfb1f4024415c6e languageName: node linkType: hard @@ -8863,7 +8863,7 @@ __metadata: resolution: "api@workspace:frontends/api" dependencies: "@faker-js/faker": "npm:^10.0.0" - "@mitodl/mitxonline-api-axios": "npm:^2026.3.23" + "@mitodl/mitxonline-api-axios": "npm:^2026.3.24" "@tanstack/react-query": "npm:^5.66.0" "@testing-library/react": "npm:^16.3.0" axios: "npm:^1.12.2" @@ -16058,7 +16058,7 @@ __metadata: "@floating-ui/react": "npm:^0.27.16" "@happy-dom/jest-environment": "npm:^20.1.0" "@mitodl/course-search-utils": "npm:^3.5.2" - "@mitodl/mitxonline-api-axios": "npm:^2026.3.23" + "@mitodl/mitxonline-api-axios": "npm:^2026.3.24" "@mitodl/smoot-design": "npm:^6.24.0" "@mui/material": "npm:^6.4.5" "@mui/material-nextjs": "npm:^6.4.3" From 0003342b47730b0190ab8b1eda7729ee58be752a Mon Sep 17 00:00:00 2001 From: Chris Chudzicki Date: Wed, 25 Mar 2026 09:24:18 -0400 Subject: [PATCH 2/7] feat: add ProgramBundleUpsell to ProgramAsCoursePage (#3092) Now that the programs API returns parent program info, display the bundle upsell on program-as-course pages the same way we do on course pages. Closes #10644 Co-authored-by: Claude Opus 4.6 (1M context) --- .../ProductPages/InfoBoxProgramAsCourse.tsx | 4 ++++ .../ProductPages/ProgramAsCoursePage.test.tsx | 23 +++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/frontends/main/src/app-pages/ProductPages/InfoBoxProgramAsCourse.tsx b/frontends/main/src/app-pages/ProductPages/InfoBoxProgramAsCourse.tsx index 62f9f18f6c..6f3ddf767c 100644 --- a/frontends/main/src/app-pages/ProductPages/InfoBoxProgramAsCourse.tsx +++ b/frontends/main/src/app-pages/ProductPages/InfoBoxProgramAsCourse.tsx @@ -7,6 +7,7 @@ import type { import { HeadingIds } from "./util" import { ProgramAsCourseSummary } from "./ProductSummary" import ProgramEnrollmentButton from "./ProgramEnrollmentButton" +import ProgramBundleUpsell from "./ProgramBundleUpsell" import { InfoBoxCard, InfoBoxContent, InfoBoxEnrollArea } from "./InfoBoxParts" type ProgramAsCourseInfoBoxProps = { @@ -29,6 +30,9 @@ const ProgramAsCourseInfoBox: React.FC = ({ + {program.programs?.length ? ( + + ) : null} ) } diff --git a/frontends/main/src/app-pages/ProductPages/ProgramAsCoursePage.test.tsx b/frontends/main/src/app-pages/ProductPages/ProgramAsCoursePage.test.tsx index fe5b467568..3bde0103eb 100644 --- a/frontends/main/src/app-pages/ProductPages/ProgramAsCoursePage.test.tsx +++ b/frontends/main/src/app-pages/ProductPages/ProgramAsCoursePage.test.tsx @@ -231,6 +231,29 @@ describe("ProgramAsCoursePage", () => { }) }) + test("Renders program bundle upsell when program belongs to a parent program (content tested in ProgramBundleUpsell.test)", async () => { + const parentProgram = factories.programs.baseProgram() + const parentProgramDetail = factories.programs.program({ + id: parentProgram.id, + readable_id: parentProgram.readable_id, + products: [factories.courses.product({ price: "500" })], + }) + const program = makeProgramAsCourse({ programs: [parentProgram] }) + const page = makePage({ program_details: program }) + setupApis({ program, page }) + setMockResponse.get( + urls.programs.programsList({ id: [parentProgram.id] }), + { results: [parentProgramDetail] }, + ) + renderWithProviders( + , + ) + + expect( + await screen.findByTestId("program-bundle-upsell"), + ).toBeInTheDocument() + }) + test("Renders Modules section with mixed courses and programs (single root)", async () => { const reqTree = new RequirementTreeBuilder() const op = reqTree.addOperator({ operator: "all_of" }) From d1c6370d5c62b467853d04c3f83cd449b46adcb8 Mon Sep 17 00:00:00 2001 From: Shankar Ambady Date: Wed, 25 Mar 2026 09:30:30 -0400 Subject: [PATCH 3/7] Upgrade Qdrant (#3087) * Update qdrant point id keys (#2990) * unify key generation for point ids * fix tests * adding platform to vector key * fix tests * fixing other methods requiring point key * fix point key * fixing test * account for platform=None * Vector Search: hybrid search (#3060) * adding sparse encoder util * adding sparse encoder setting * add sparse enc * adding sparse hash encoder * adding scikit-learn * fix sparse encoder * fix topic embedding' * fix default vectorizer name * adding cloud inference capability * adding openai api key to options dict * fix limits * docstring updates * adding test * some optimizations * fixing limit for prefetch queries * hide hybrid search behind posthog feature flag * scale prefetch with offset * fix yield return * fix sparse hash threshold calculation * switching hybrid search to be a url param * remove search params from groupby * adding cache decorator to sparse encoder * fix test * fix test * add default encoding name * fix tests * fix stop_words param * adding test for hybrid flag and group_by * pinning tokenizer to None for tests * fix sparse embedding when searching * temporarily pin to _V2 settings for the cutover (#3077) * set max prefetch for hybrid searches * switch to settings for prefetch multiplier * Update vector_search/utils_test.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- frontends/api/src/generated/v0/api.ts | 36 ++++ learning_resources_search/api.py | 12 +- learning_resources_search/api_test.py | 2 +- main/settings.py | 18 +- openapi/specs/v0.yaml | 12 ++ pyproject.toml | 1 + uv.lock | 52 ++++++ vector_search/conftest.py | 5 + vector_search/constants.py | 1 + vector_search/encoders/base.py | 2 + vector_search/encoders/qdrant_cloud.py | 44 +++++ vector_search/encoders/sparse_hash.py | 39 +++++ vector_search/encoders/utils.py | 11 ++ vector_search/serializers.py | 10 ++ vector_search/tasks.py | 23 ++- vector_search/utils.py | 233 +++++++++++++++++++------ vector_search/utils_test.py | 157 ++++++++++++++++- vector_search/views.py | 9 +- 18 files changed, 591 insertions(+), 76 deletions(-) create mode 100644 vector_search/encoders/qdrant_cloud.py create mode 100644 vector_search/encoders/sparse_hash.py diff --git a/frontends/api/src/generated/v0/api.ts b/frontends/api/src/generated/v0/api.ts index fd5ff0beb3..e39f92eeec 100644 --- a/frontends/api/src/generated/v0/api.ts +++ b/frontends/api/src/generated/v0/api.ts @@ -11515,6 +11515,7 @@ export const VectorContentFilesSearchApiAxiosParamCreator = function ( * @param {Array} [file_extension] The extension of the content file. * @param {string} [group_by] The attribute to group results by * @param {number} [group_size] The number of chunks in each group. Only relevant when group_by is used + * @param {boolean} [hybrid_search] Whether to use a hybrid search * @param {Array} [key] The filename of the content file * @param {number} [limit] Number of results to return per page * @param {Array} [offered_by] Offeror of the content file @@ -11539,6 +11540,7 @@ export const VectorContentFilesSearchApiAxiosParamCreator = function ( file_extension?: Array, group_by?: string, group_size?: number, + hybrid_search?: boolean, key?: Array, limit?: number, offered_by?: Array, @@ -11598,6 +11600,10 @@ export const VectorContentFilesSearchApiAxiosParamCreator = function ( localVarQueryParameter["group_size"] = group_size } + if (hybrid_search !== undefined) { + localVarQueryParameter["hybrid_search"] = hybrid_search + } + if (key) { localVarQueryParameter["key"] = key } @@ -11687,6 +11693,7 @@ export const VectorContentFilesSearchApiFp = function ( * @param {Array} [file_extension] The extension of the content file. * @param {string} [group_by] The attribute to group results by * @param {number} [group_size] The number of chunks in each group. Only relevant when group_by is used + * @param {boolean} [hybrid_search] Whether to use a hybrid search * @param {Array} [key] The filename of the content file * @param {number} [limit] Number of results to return per page * @param {Array} [offered_by] Offeror of the content file @@ -11711,6 +11718,7 @@ export const VectorContentFilesSearchApiFp = function ( file_extension?: Array, group_by?: string, group_size?: number, + hybrid_search?: boolean, key?: Array, limit?: number, offered_by?: Array, @@ -11740,6 +11748,7 @@ export const VectorContentFilesSearchApiFp = function ( file_extension, group_by, group_size, + hybrid_search, key, limit, offered_by, @@ -11802,6 +11811,7 @@ export const VectorContentFilesSearchApiFactory = function ( requestParameters.file_extension, requestParameters.group_by, requestParameters.group_size, + requestParameters.hybrid_search, requestParameters.key, requestParameters.limit, requestParameters.offered_by, @@ -11877,6 +11887,13 @@ export interface VectorContentFilesSearchApiVectorContentFilesSearchRetrieveRequ */ readonly group_size?: number + /** + * Whether to use a hybrid search + * @type {boolean} + * @memberof VectorContentFilesSearchApiVectorContentFilesSearchRetrieve + */ + readonly hybrid_search?: boolean + /** * The filename of the content file * @type {Array} @@ -11997,6 +12014,7 @@ export class VectorContentFilesSearchApi extends BaseAPI { requestParameters.file_extension, requestParameters.group_by, requestParameters.group_size, + requestParameters.hybrid_search, requestParameters.key, requestParameters.limit, requestParameters.offered_by, @@ -12045,6 +12063,7 @@ export const VectorLearningResourcesSearchApiAxiosParamCreator = function ( * @param {Array} [delivery] The delivery options in which the learning resource is offered * `online` - Online * `hybrid` - Hybrid * `in_person` - In person * `offline` - Offline * @param {Array} [department] The department that offers the learning resource * `1` - Civil and Environmental Engineering * `2` - Mechanical Engineering * `3` - Materials Science and Engineering * `4` - Architecture * `5` - Chemistry * `6` - Electrical Engineering and Computer Science * `7` - Biology * `8` - Physics * `9` - Brain and Cognitive Sciences * `10` - Chemical Engineering * `11` - Urban Studies and Planning * `12` - Earth, Atmospheric, and Planetary Sciences * `14` - Economics * `15` - Management * `16` - Aeronautics and Astronautics * `17` - Political Science * `18` - Mathematics * `20` - Biological Engineering * `21A` - Anthropology * `21G` - Global Languages * `21H` - History * `21L` - Literature * `21M` - Music and Theater Arts * `22` - Nuclear Science and Engineering * `24` - Linguistics and Philosophy * `CC` - Concourse * `CMS-W` - Comparative Media Studies/Writing * `EC` - Edgerton Center * `ES` - Experimental Study Group * `ESD` - Engineering Systems Division * `HST` - Medical Engineering and Science * `IDS` - Data, Systems, and Society * `MAS` - Media Arts and Sciences * `PE` - Athletics, Physical Education and Recreation * `SP` - Special Programs * `STS` - Science, Technology, and Society * `WGS` - Women\'s and Gender Studies * @param {boolean | null} [free] + * @param {boolean} [hybrid_search] Whether to use a hybrid search * @param {Array} [level] * @param {number} [limit] Number of results to return per page * @param {Array} [ocw_topic] The ocw topic name. @@ -12069,6 +12088,7 @@ export const VectorLearningResourcesSearchApiAxiosParamCreator = function ( delivery?: Array, department?: Array, free?: boolean | null, + hybrid_search?: boolean, level?: Array, limit?: number, ocw_topic?: Array, @@ -12125,6 +12145,10 @@ export const VectorLearningResourcesSearchApiAxiosParamCreator = function ( localVarQueryParameter["free"] = free } + if (hybrid_search !== undefined) { + localVarQueryParameter["hybrid_search"] = hybrid_search + } + if (level) { localVarQueryParameter["level"] = level } @@ -12217,6 +12241,7 @@ export const VectorLearningResourcesSearchApiFp = function ( * @param {Array} [delivery] The delivery options in which the learning resource is offered * `online` - Online * `hybrid` - Hybrid * `in_person` - In person * `offline` - Offline * @param {Array} [department] The department that offers the learning resource * `1` - Civil and Environmental Engineering * `2` - Mechanical Engineering * `3` - Materials Science and Engineering * `4` - Architecture * `5` - Chemistry * `6` - Electrical Engineering and Computer Science * `7` - Biology * `8` - Physics * `9` - Brain and Cognitive Sciences * `10` - Chemical Engineering * `11` - Urban Studies and Planning * `12` - Earth, Atmospheric, and Planetary Sciences * `14` - Economics * `15` - Management * `16` - Aeronautics and Astronautics * `17` - Political Science * `18` - Mathematics * `20` - Biological Engineering * `21A` - Anthropology * `21G` - Global Languages * `21H` - History * `21L` - Literature * `21M` - Music and Theater Arts * `22` - Nuclear Science and Engineering * `24` - Linguistics and Philosophy * `CC` - Concourse * `CMS-W` - Comparative Media Studies/Writing * `EC` - Edgerton Center * `ES` - Experimental Study Group * `ESD` - Engineering Systems Division * `HST` - Medical Engineering and Science * `IDS` - Data, Systems, and Society * `MAS` - Media Arts and Sciences * `PE` - Athletics, Physical Education and Recreation * `SP` - Special Programs * `STS` - Science, Technology, and Society * `WGS` - Women\'s and Gender Studies * @param {boolean | null} [free] + * @param {boolean} [hybrid_search] Whether to use a hybrid search * @param {Array} [level] * @param {number} [limit] Number of results to return per page * @param {Array} [ocw_topic] The ocw topic name. @@ -12241,6 +12266,7 @@ export const VectorLearningResourcesSearchApiFp = function ( delivery?: Array, department?: Array, free?: boolean | null, + hybrid_search?: boolean, level?: Array, limit?: number, ocw_topic?: Array, @@ -12270,6 +12296,7 @@ export const VectorLearningResourcesSearchApiFp = function ( delivery, department, free, + hybrid_search, level, limit, ocw_topic, @@ -12332,6 +12359,7 @@ export const VectorLearningResourcesSearchApiFactory = function ( requestParameters.delivery, requestParameters.department, requestParameters.free, + requestParameters.hybrid_search, requestParameters.level, requestParameters.limit, requestParameters.ocw_topic, @@ -12401,6 +12429,13 @@ export interface VectorLearningResourcesSearchApiVectorLearningResourcesSearchRe */ readonly free?: boolean | null + /** + * Whether to use a hybrid search + * @type {boolean} + * @memberof VectorLearningResourcesSearchApiVectorLearningResourcesSearchRetrieve + */ + readonly hybrid_search?: boolean + /** * * @type {Array<'undergraduate' | 'graduate' | 'high_school' | 'noncredit' | 'advanced' | 'intermediate' | 'introductory'>} @@ -12527,6 +12562,7 @@ export class VectorLearningResourcesSearchApi extends BaseAPI { requestParameters.delivery, requestParameters.department, requestParameters.free, + requestParameters.hybrid_search, requestParameters.level, requestParameters.limit, requestParameters.ocw_topic, diff --git a/learning_resources_search/api.py b/learning_resources_search/api.py index d220618c07..edfc5e82d5 100644 --- a/learning_resources_search/api.py +++ b/learning_resources_search/api.py @@ -11,6 +11,9 @@ from opensearchpy.exceptions import NotFoundError from learning_resources.models import LearningResource +from learning_resources.serializers import ( + LearningResourceSerializer, +) from learning_resources_search.connection import ( get_default_alias_name, get_vector_model_id, @@ -971,7 +974,7 @@ def user_subscribed_to_query( def get_similar_topics_qdrant( resource: LearningResource, value_doc: dict, num_topics: int ) -> list[str]: - from vector_search.utils import qdrant_client, vector_point_id + from vector_search.utils import qdrant_client, vector_point_id, vector_point_key """ Get a list of similar topics based on vector similarity @@ -987,10 +990,11 @@ def get_similar_topics_qdrant( """ encoder = dense_encoder() client = qdrant_client() + serialized_resource = LearningResourceSerializer(resource).data response = client.retrieve( collection_name=RESOURCES_COLLECTION_NAME, - ids=[vector_point_id(resource.readable_id)], + ids=[vector_point_id(vector_point_key(serialized_resource))], with_vectors=True, ) @@ -1145,10 +1149,10 @@ def get_similar_resources_qdrant(value_doc: dict, num_resources: int): list of str: list of learning resources """ - from vector_search.utils import vector_point_id + from vector_search.utils import vector_point_id, vector_point_key hits = _qdrant_similar_results( - input_query=vector_point_id(value_doc["readable_id"]), + input_query=vector_point_id(vector_point_key(value_doc)), num_resources=num_resources, ) return ( diff --git a/learning_resources_search/api_test.py b/learning_resources_search/api_test.py index f060e5dc18..c9a69f7d04 100644 --- a/learning_resources_search/api_test.py +++ b/learning_resources_search/api_test.py @@ -4155,7 +4155,7 @@ def test_get_similar_topics_qdrant_uses_cached_embedding(mocker): """ Test that get_similar_topics_qdrant uses a cached embedding when available """ - resource = MagicMock() + resource = LearningResourceFactory.create() resource.readable_id = "test-resource" value_doc = {"title": "Test Title", "description": "Test Description"} num_topics = 3 diff --git a/main/settings.py b/main/settings.py index 95fb0107a6..be1a9dc2a0 100644 --- a/main/settings.py +++ b/main/settings.py @@ -782,14 +782,20 @@ def get_all_config_keys(): name="QDRANT_ENABLE_INDEXING_PLUGIN_HOOKS", default=False ) -QDRANT_API_KEY = get_string(name="QDRANT_API_KEY", default="") -QDRANT_HOST = get_string(name="QDRANT_HOST", default="http://qdrant:6333") +QDRANT_API_KEY = get_string(name="QDRANT_API_KEY_V2", default="") +QDRANT_HOST = get_string(name="QDRANT_HOST_V2", default="http://qdrant:6333") + + QDRANT_BASE_COLLECTION_NAME = get_string( name="QDRANT_COLLECTION_NAME", default="resource_embeddings" ) QDRANT_DENSE_MODEL = get_string(name="QDRANT_DENSE_MODEL", default=None) QDRANT_SPARSE_MODEL = get_string( - name="QDRANT_SPARSE_MODEL", default="prithivida/Splade_PP_en_v1" + name="QDRANT_SPARSE_MODEL_V2", default="sklearn/hashing_vectorizer_sparse_model" +) +QDRANT_SPARSE_ENCODER = get_string( + name="QDRANT_SPARSE_ENCODER_V2", + default="vector_search.encoders.sparse_hash.SparseHashEncoder", ) QDRANT_CHUNK_SIZE = get_int( @@ -811,6 +817,12 @@ def get_all_config_keys(): QDRANT_CLIENT_TIMEOUT = get_int(name="QDRANT_CLIENT_TIMEOUT", default=10) +VECTOR_HYBRID_SEARCH_PREFETCH_MULTIPLIER = get_int( + name="VECTOR_HYBRID_SEARCH_PREFETCH_MULTIPLIER", default=20 +) +VECTOR_HYBRID_SEARCH_PREFETCH_MAX_LIMIT = get_int( + name="VECTOR_HYBRID_SEARCH_PREFETCH_MAX_LIMIT", default=10000 +) # toggle to use requests (default for local) or webdriver which renders js elements EMBEDDINGS_EXTERNAL_FETCH_USE_WEBDRIVER = get_bool( "EMBEDDINGS_EXTERNAL_FETCH_USE_WEBDRIVER", default=False diff --git a/openapi/specs/v0.yaml b/openapi/specs/v0.yaml index 4a28d8d832..9a4baa8e28 100644 --- a/openapi/specs/v0.yaml +++ b/openapi/specs/v0.yaml @@ -878,6 +878,12 @@ paths: type: integer description: The number of chunks in each group. Only relevant when group_by is used + - in: query + name: hybrid_search + schema: + type: boolean + default: false + description: Whether to use a hybrid search - in: query name: key schema: @@ -1153,6 +1159,12 @@ paths: schema: type: boolean nullable: true + - in: query + name: hybrid_search + schema: + type: boolean + default: false + description: Whether to use a hybrid search - in: query name: level schema: diff --git a/pyproject.toml b/pyproject.toml index 828b0002ea..8b56683661 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -114,6 +114,7 @@ dependencies = [ "opencv-python>=4.12.0.88,<5", "django-removals>=1.1.6,<2", "langchain-litellm>=0.5.1", + "scikit-learn>=1.8.0", ] [dependency-groups] diff --git a/uv.lock b/uv.lock index 175d6ea07b..c2bf6e89e8 100644 --- a/uv.lock +++ b/uv.lock @@ -2545,6 +2545,7 @@ dependencies = [ { name = "redis" }, { name = "requests" }, { name = "retry2" }, + { name = "scikit-learn" }, { name = "selenium" }, { name = "sentry-sdk" }, { name = "social-auth-app-django" }, @@ -2679,6 +2680,7 @@ requires-dist = [ { name = "redis", specifier = ">=7.0.0,<8" }, { name = "requests", specifier = ">=2.31.0,<3" }, { name = "retry2", specifier = ">=0.9.5,<0.10" }, + { name = "scikit-learn", specifier = ">=1.8.0" }, { name = "selenium", specifier = ">=4.30.0,<5" }, { name = "sentry-sdk", specifier = ">=2.25.1,<3" }, { name = "social-auth-app-django", specifier = ">=5.2.0,<6" }, @@ -4466,6 +4468,26 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/84/a2/7840cc32890ce4b84668d3d9dfe15a48355b683ae3fb627ac97ac5a4265f/safety_schemas-0.0.16-py3-none-any.whl", hash = "sha256:6760515d3fd1e6535b251cd73014bd431d12fe0bfb8b6e8880a9379b5ab7aa44", size = 39292, upload-time = "2025-09-16T14:35:32.84Z" }, ] +[[package]] +name = "scikit-learn" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "joblib" }, + { name = "numpy" }, + { name = "scipy" }, + { name = "threadpoolctl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0e/d4/40988bf3b8e34feec1d0e6a051446b1f66225f8529b9309becaeef62b6c4/scikit_learn-1.8.0.tar.gz", hash = "sha256:9bccbb3b40e3de10351f8f5068e105d0f4083b1a65fa07b6634fbc401a6287fd", size = 7335585, upload-time = "2025-12-10T07:08:53.618Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/74/e6a7cc4b820e95cc38cf36cd74d5aa2b42e8ffc2d21fe5a9a9c45c1c7630/scikit_learn-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5fb63362b5a7ddab88e52b6dbb47dac3fd7dafeee740dc6c8d8a446ddedade8e", size = 8548242, upload-time = "2025-12-10T07:07:51.568Z" }, + { url = "https://files.pythonhosted.org/packages/49/d8/9be608c6024d021041c7f0b3928d4749a706f4e2c3832bbede4fb4f58c95/scikit_learn-1.8.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:5025ce924beccb28298246e589c691fe1b8c1c96507e6d27d12c5fadd85bfd76", size = 8079075, upload-time = "2025-12-10T07:07:53.697Z" }, + { url = "https://files.pythonhosted.org/packages/dd/47/f187b4636ff80cc63f21cd40b7b2d177134acaa10f6bb73746130ee8c2e5/scikit_learn-1.8.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4496bb2cf7a43ce1a2d7524a79e40bc5da45cf598dbf9545b7e8316ccba47bb4", size = 8660492, upload-time = "2025-12-10T07:07:55.574Z" }, + { url = "https://files.pythonhosted.org/packages/97/74/b7a304feb2b49df9fafa9382d4d09061a96ee9a9449a7cbea7988dda0828/scikit_learn-1.8.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0bcfe4d0d14aec44921545fd2af2338c7471de9cb701f1da4c9d85906ab847a", size = 8931904, upload-time = "2025-12-10T07:07:57.666Z" }, + { url = "https://files.pythonhosted.org/packages/9f/c4/0ab22726a04ede56f689476b760f98f8f46607caecff993017ac1b64aa5d/scikit_learn-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:35c007dedb2ffe38fe3ee7d201ebac4a2deccd2408e8621d53067733e3c74809", size = 8019359, upload-time = "2025-12-10T07:07:59.838Z" }, + { url = "https://files.pythonhosted.org/packages/24/90/344a67811cfd561d7335c1b96ca21455e7e472d281c3c279c4d3f2300236/scikit_learn-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:8c497fff237d7b4e07e9ef1a640887fa4fb765647f86fbe00f969ff6280ce2bb", size = 7641898, upload-time = "2025-12-10T07:08:01.36Z" }, +] + [[package]] name = "scim2-filter-parser" version = "0.7.0" @@ -4478,6 +4500,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/57/54/b54961bfc5018fa593758c439fe0d4a22fbadfabff49a7559850af9a79e1/scim2_filter_parser-0.7.0-py3-none-any.whl", hash = "sha256:a74f90a2d52a77e0f1bc4d77e84b79f88749469f6f7192d64a4f92e4fe50ab69", size = 23409, upload-time = "2024-07-20T16:38:21.525Z" }, ] +[[package]] +name = "scipy" +version = "1.17.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/35/48/b992b488d6f299dbe3f11a20b24d3dda3d46f1a635ede1c46b5b17a7b163/scipy-1.17.1-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:35c3a56d2ef83efc372eaec584314bd0ef2e2f0d2adb21c55e6ad5b344c0dcb8", size = 31610954, upload-time = "2026-02-23T00:17:49.855Z" }, + { url = "https://files.pythonhosted.org/packages/b2/02/cf107b01494c19dc100f1d0b7ac3cc08666e96ba2d64db7626066cee895e/scipy-1.17.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:fcb310ddb270a06114bb64bbe53c94926b943f5b7f0842194d585c65eb4edd76", size = 28172662, upload-time = "2026-02-23T00:18:01.64Z" }, + { url = "https://files.pythonhosted.org/packages/cf/a9/599c28631bad314d219cf9ffd40e985b24d603fc8a2f4ccc5ae8419a535b/scipy-1.17.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:cc90d2e9c7e5c7f1a482c9875007c095c3194b1cfedca3c2f3291cdc2bc7c086", size = 20344366, upload-time = "2026-02-23T00:18:12.015Z" }, + { url = "https://files.pythonhosted.org/packages/35/f5/906eda513271c8deb5af284e5ef0206d17a96239af79f9fa0aebfe0e36b4/scipy-1.17.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:c80be5ede8f3f8eded4eff73cc99a25c388ce98e555b17d31da05287015ffa5b", size = 22704017, upload-time = "2026-02-23T00:18:21.502Z" }, + { url = "https://files.pythonhosted.org/packages/da/34/16f10e3042d2f1d6b66e0428308ab52224b6a23049cb2f5c1756f713815f/scipy-1.17.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e19ebea31758fac5893a2ac360fedd00116cbb7628e650842a6691ba7ca28a21", size = 32927842, upload-time = "2026-02-23T00:18:35.367Z" }, + { url = "https://files.pythonhosted.org/packages/01/8e/1e35281b8ab6d5d72ebe9911edcdffa3f36b04ed9d51dec6dd140396e220/scipy-1.17.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:02ae3b274fde71c5e92ac4d54bc06c42d80e399fec704383dcd99b301df37458", size = 35235890, upload-time = "2026-02-23T00:18:49.188Z" }, + { url = "https://files.pythonhosted.org/packages/c5/5c/9d7f4c88bea6e0d5a4f1bc0506a53a00e9fcb198de372bfe4d3652cef482/scipy-1.17.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8a604bae87c6195d8b1045eddece0514d041604b14f2727bbc2b3020172045eb", size = 35003557, upload-time = "2026-02-23T00:18:54.74Z" }, + { url = "https://files.pythonhosted.org/packages/65/94/7698add8f276dbab7a9de9fb6b0e02fc13ee61d51c7c3f85ac28b65e1239/scipy-1.17.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f590cd684941912d10becc07325a3eeb77886fe981415660d9265c4c418d0bea", size = 37625856, upload-time = "2026-02-23T00:19:00.307Z" }, + { url = "https://files.pythonhosted.org/packages/a2/84/dc08d77fbf3d87d3ee27f6a0c6dcce1de5829a64f2eae85a0ecc1f0daa73/scipy-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:41b71f4a3a4cab9d366cd9065b288efc4d4f3c0b37a91a8e0947fb5bd7f31d87", size = 36549682, upload-time = "2026-02-23T00:19:07.67Z" }, + { url = "https://files.pythonhosted.org/packages/bc/98/fe9ae9ffb3b54b62559f52dedaebe204b408db8109a8c66fdd04869e6424/scipy-1.17.1-cp312-cp312-win_arm64.whl", hash = "sha256:f4115102802df98b2b0db3cce5cb9b92572633a1197c77b7553e5203f284a5b3", size = 24547340, upload-time = "2026-02-23T00:19:12.024Z" }, +] + [[package]] name = "selenium" version = "4.41.0" @@ -4742,6 +4785,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55", size = 28926, upload-time = "2026-02-07T10:45:32.24Z" }, ] +[[package]] +name = "threadpoolctl" +version = "3.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b7/4d/08c89e34946fce2aec4fbb45c9016efd5f4d7f24af8e5d93296e935631d8/threadpoolctl-3.6.0.tar.gz", hash = "sha256:8ab8b4aa3491d812b623328249fab5302a68d2d71745c8a4c719a2fcaba9f44e", size = 21274, upload-time = "2025-03-13T13:49:23.031Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/d5/f9a850d79b0851d1d4ef6456097579a9005b31fea68726a4ae5f2d82ddd9/threadpoolctl-3.6.0-py3-none-any.whl", hash = "sha256:43a0b8fd5a2928500110039e43a5eed8480b918967083ea48dc3ab9f13c4a7fb", size = 18638, upload-time = "2025-03-13T13:49:21.846Z" }, +] + [[package]] name = "tika" version = "3.1.0" diff --git a/vector_search/conftest.py b/vector_search/conftest.py index cdc757259d..ef62997ffd 100644 --- a/vector_search/conftest.py +++ b/vector_search/conftest.py @@ -31,8 +31,13 @@ def _use_test_qdrant_settings(settings, mocker): settings.QDRANT_HOST = "https://test" settings.QDRANT_BASE_COLLECTION_NAME = "test" settings.LITELLM_API_BASE = "https://test/api/" + settings.LITELLM_TOKEN_ENCODING_NAME = None settings.CONTENT_FILE_EMBEDDING_CHUNK_OVERLAP = 0 settings.CONTENT_FILE_EMBEDDING_SEMANTIC_CHUNKING_ENABLED = False + settings.QDRANT_SPARSE_MODEL = "sklearn/hashing_vectorizer_sparse_model" + settings.QDRANT_SPARSE_ENCODER = ( + "vector_search.encoders.sparse_hash.SparseHashEncoder" + ) mock_qdrant = mocker.patch("qdrant_client.QdrantClient") mocker.patch("vector_search.utils.SemanticChunker") diff --git a/vector_search/constants.py b/vector_search/constants.py index 121c28930a..7e552febae 100644 --- a/vector_search/constants.py +++ b/vector_search/constants.py @@ -5,6 +5,7 @@ CONTENT_FILES_COLLECTION_NAME = f"{settings.QDRANT_BASE_COLLECTION_NAME}.content_files" TOPICS_COLLECTION_NAME = f"{settings.QDRANT_BASE_COLLECTION_NAME}.topics" + QDRANT_CONTENT_FILE_PARAM_MAP = { "key": "key", "course_number": "course_number", diff --git a/vector_search/encoders/base.py b/vector_search/encoders/base.py index 2ff4e9d844..301793410e 100644 --- a/vector_search/encoders/base.py +++ b/vector_search/encoders/base.py @@ -9,6 +9,8 @@ class BaseEncoder(Embeddings, ABC): Conforms to the langchain Embeddings interface """ + requires_cloud_inferencing = False + def model_short_name(self): """ Return the short name of the model diff --git a/vector_search/encoders/qdrant_cloud.py b/vector_search/encoders/qdrant_cloud.py new file mode 100644 index 0000000000..e3beccf3c7 --- /dev/null +++ b/vector_search/encoders/qdrant_cloud.py @@ -0,0 +1,44 @@ +import logging + +import tiktoken +from django.conf import settings +from qdrant_client import models + +from vector_search.encoders.base import BaseEncoder + +log = logging.getLogger() + + +class QdrantCloudEncoder(BaseEncoder): + """ + Qdrant native inferencing cloud encoder + """ + + requires_cloud_inferencing = True + + def __init__(self, model_name): + self.model_name = model_name + try: + self.token_encoding_name = tiktoken.encoding_name_for_model(model_name) + except KeyError: + msg = f"Model {model_name} not found in tiktoken. defaulting to None" + log.warning(msg) + + def embed_documents(self, documents): + return self.get_embedding(documents) + + def get_embedding(self, texts): + """ + Return Documents with text and model name for qdrant cloud inferencing. + """ + return [ + models.Document( + text=text, + model=self.model_name, + options={ + # required for openai models + "openai-api-key": settings.OPENAI_API_KEY, + }, + ) + for text in texts + ] diff --git a/vector_search/encoders/sparse_hash.py b/vector_search/encoders/sparse_hash.py new file mode 100644 index 0000000000..c2f9b3523a --- /dev/null +++ b/vector_search/encoders/sparse_hash.py @@ -0,0 +1,39 @@ +from qdrant_client import models +from sklearn.feature_extraction.text import HashingVectorizer + +from vector_search.encoders.base import BaseEncoder + + +class SparseHashEncoder(BaseEncoder): + """ + Sparse Hash Encoder + """ + + def __init__(self, model_name="sklearn/hashing_vectorizer_sparse_model"): + self.model_name = model_name + self.vectorizer = HashingVectorizer(stop_words="english") + + def prune_sparse_vector(self, vec, threshold=0.1): + return { + "indices": [ + i for i, v in zip(vec["indices"], vec["values"]) if abs(v) > threshold + ], + "values": [v for v in vec["values"] if abs(v) > threshold], + } + + def embed_documents(self, documents): + return [self.embed(doc) for doc in documents] + + def embed(self, text): + tfidf_matrix = self.vectorizer.transform([text]) + indices = tfidf_matrix.indices.tolist() + values = tfidf_matrix.data.tolist() + return models.SparseVector( + **self.prune_sparse_vector({"indices": indices, "values": values}) + ) + + def dim(self): + """ + Return the dimension of the embeddings + """ + return 0 diff --git a/vector_search/encoders/utils.py b/vector_search/encoders/utils.py index 644a96e158..5d970fd58d 100644 --- a/vector_search/encoders/utils.py +++ b/vector_search/encoders/utils.py @@ -13,3 +13,14 @@ def dense_encoder(): if settings.QDRANT_DENSE_MODEL: return Encoder(model_name=settings.QDRANT_DENSE_MODEL) return Encoder() + + +@cache +def sparse_encoder(): + """ + Return the sparse encoder based on settings + """ + Encoder = import_string(settings.QDRANT_SPARSE_ENCODER) + if settings.QDRANT_SPARSE_MODEL: + return Encoder(model_name=settings.QDRANT_SPARSE_MODEL) + return Encoder() diff --git a/vector_search/serializers.py b/vector_search/serializers.py index c11cb902a7..6672d79541 100644 --- a/vector_search/serializers.py +++ b/vector_search/serializers.py @@ -160,6 +160,11 @@ class LearningResourcesVectorSearchRequestSerializer(serializers.Serializer): allow_null=True, help_text="Filter to learning resources where title is null/not null", ) + hybrid_search = serializers.BooleanField( + required=False, + default=False, + help_text="Whether to use a hybrid search", + ) class LearningResourcesVectorSearchResponseSerializer(SearchResponseSerializer): @@ -283,6 +288,11 @@ class ContentFileVectorSearchRequestSerializer(serializers.Serializer): allow_null=True, help_text="Filter to content files where title is null/not null", ) + hybrid_search = serializers.BooleanField( + required=False, + default=False, + help_text="Whether to use a hybrid search", + ) class ContentFileVectorSearchResponseSerializer(SearchResponseSerializer): diff --git a/vector_search/tasks.py b/vector_search/tasks.py index b8bc9f8e78..e2fbaa3f68 100644 --- a/vector_search/tasks.py +++ b/vector_search/tasks.py @@ -13,6 +13,10 @@ Course, LearningResource, ) +from learning_resources.serializers import ( + ContentFileSerializer, + LearningResourceSerializer, +) from learning_resources.utils import load_course_blocklist from learning_resources_search.constants import ( CONTENT_FILE_TYPE, @@ -21,6 +25,9 @@ SEARCH_CONN_EXCEPTIONS, ) from learning_resources_search.exceptions import RetryError +from learning_resources_search.serializers import ( + serialize_bulk_learning_resources, +) from learning_resources_search.tasks import wrap_retry_exception from main.celery import app from main.utils import ( @@ -37,6 +44,7 @@ filter_existing_qdrant_points_by_ids, remove_qdrant_records, vector_point_id, + vector_point_key, ) log = logging.getLogger(__name__) @@ -380,14 +388,18 @@ def embeddings_healthcheck(): if lr.best_run else lr.runs.filter(published=True).order_by("-start_date").first() ) - point_id = vector_point_id(lr.readable_id) + serialized = LearningResourceSerializer(lr).data + point_id = vector_point_id(vector_point_key(serialized)) resource_point_ids[point_id] = {"resource_id": lr.readable_id, "id": lr.id} content_file_point_ids = {} if run: for cf in run.content_files.filter(published=True): if cf and cf.content: + serialized_cf = ContentFileSerializer(cf).data point_id = vector_point_id( - f"{lr.readable_id}.{run.run_id}.{cf.key}.0" + vector_point_key( + serialized_cf, chunk_number=0, document_type="content_file" + ) ) content_file_point_ids[point_id] = {"key": cf.key, "id": cf.id} for batch in chunks(content_file_point_ids.keys(), chunk_size=200): @@ -402,12 +414,15 @@ def embeddings_healthcheck(): ) for batch in chunks( - all_resources.values_list("readable_id", flat=True), + all_resources.values_list("id", flat=True), chunk_size=200, ): remaining_resources.extend( filter_existing_qdrant_points_by_ids( - [vector_point_id(pid) for pid in batch], + [ + vector_point_id(vector_point_key(serialized_resource)) + for serialized_resource in serialize_bulk_learning_resources(batch) + ], collection_name=RESOURCES_COLLECTION_NAME, ) ) diff --git a/vector_search/utils.py b/vector_search/utils.py index 904d251796..441dcb4919 100644 --- a/vector_search/utils.py +++ b/vector_search/utils.py @@ -40,18 +40,24 @@ RESOURCES_COLLECTION_NAME, TOPICS_COLLECTION_NAME, ) -from vector_search.encoders.utils import dense_encoder +from vector_search.encoders.utils import dense_encoder, sparse_encoder logger = logging.getLogger(__name__) @cache def qdrant_client(): + enable_cloud_inference = ( + dense_encoder().requires_cloud_inferencing + or sparse_encoder().requires_cloud_inferencing + ) + return QdrantClient( url=settings.QDRANT_HOST, api_key=settings.QDRANT_API_KEY, grpc_port=6334, prefer_grpc=True, + cloud_inference=enable_cloud_inference, timeout=settings.QDRANT_CLIENT_TIMEOUT, ) @@ -59,8 +65,8 @@ def qdrant_client(): def points_generator( ids, metadata, - encoded_docs, - vector_name, + dense_encoded_docs, + sparse_encoded_docs, ): """ Get a generator for embedding points to store in Qdrant @@ -68,21 +74,28 @@ def points_generator( Args: ids (list): list of unique point ids metadata (list): list of metadata dictionaries - encoded_docs (list): list of vectorized documents - vector_name (str): name of the vector in qdrant + dense_encoded_docs (list): list of vectorized documents + sparse_encoded_docs Returns: generator: A generator of PointStruct objects """ + dense_vector_name = dense_encoder().model_short_name() + sparse_vector_name = sparse_encoder().model_short_name() if ids is None: ids = iter(lambda: uuid.uuid4().hex, None) if metadata is None: metadata = iter(dict, None) - for idx, meta, vector in zip(ids, metadata, encoded_docs): + for idx, meta, dense_vector, sparse_vector in zip( + ids, metadata, dense_encoded_docs, sparse_encoded_docs + ): payload = meta point_data = {"id": idx, "payload": payload} - if any(vector): - point_vector: dict[str, models.Vector] = {vector_name: vector} + if any(dense_vector): + point_vector: dict[str, models.Vector] = { + dense_vector_name: dense_vector, + sparse_vector_name: sparse_vector, + } point_data["vector"] = point_vector yield models.PointStruct(**point_data) @@ -112,7 +125,8 @@ def create_qdrant_collection(collection_name, force_recreate): Create or recreate a QDrant collection """ client = qdrant_client() - encoder = dense_encoder() + encoder_dense = dense_encoder() + encoder_sparse = sparse_encoder() # True if either of the collections were recreated if not client.collection_exists(collection_name=collection_name) or force_recreate: client.delete_collection(collection_name) @@ -120,10 +134,15 @@ def create_qdrant_collection(collection_name, force_recreate): collection_name=collection_name, on_disk_payload=True, vectors_config={ - encoder.model_short_name(): models.VectorParams( - size=encoder.dim(), distance=models.Distance.COSINE + encoder_dense.model_short_name(): models.VectorParams( + size=encoder_dense.dim(), distance=models.Distance.COSINE ), }, + sparse_vectors_config={ + encoder_sparse.model_short_name(): models.SparseVectorParams( + index=models.SparseIndexParams(on_disk=True), + ) + }, replication_factor=2, shard_number=6, strict_mode_config=models.StrictModeConfig( @@ -131,13 +150,15 @@ def create_qdrant_collection(collection_name, force_recreate): unindexed_filtering_retrieve=False, unindexed_filtering_update=False, ), - sparse_vectors_config=client.get_fastembed_sparse_vector_params(), - optimizers_config=models.OptimizersConfigDiff(default_segment_number=2), + optimizers_config=models.OptimizersConfigDiff( + default_segment_number=2, prevent_unoptimized=True + ), quantization_config=models.BinaryQuantization( binary=models.BinaryQuantizationConfig( always_ram=True, ), ), + hnsw_config=models.HnswConfigDiff(on_disk=False), ) @@ -228,10 +249,17 @@ def embed_topics(): ) ids.append(str(topic.topic_uuid)) if len(docs) > 0: - encoder = dense_encoder() - embeddings = encoder.embed_documents(docs) - vector_name = encoder.model_short_name() - points = points_generator(ids, metadata, embeddings, vector_name) + encoder_dense = dense_encoder() + encoder_sparse = sparse_encoder() + embeddings = encoder_dense.embed_documents(docs) + sparse_embeddings = encoder_sparse.embed_documents(docs) + + points = points_generator( + ids, + metadata, + dense_encoded_docs=embeddings, + sparse_encoded_docs=sparse_embeddings, + ) client.upload_points(TOPICS_COLLECTION_NAME, points=points, wait=False) @@ -289,24 +317,30 @@ def _process_resource_embeddings(serialized_resources): docs = [] metadata = [] ids = [] - encoder = dense_encoder() - vector_name = encoder.model_short_name() + encoder_dense = dense_encoder() + encoder_sparse = sparse_encoder() + for doc in serialized_resources: if not should_generate_resource_embeddings(doc): update_learning_resource_payload(doc) continue - vector_point_key = doc["readable_id"] metadata.append(doc) - ids.append(vector_point_id(vector_point_key)) + ids.append(vector_point_id(vector_point_key(doc))) docs.append(_learning_resource_embedding_context(doc)) if len(docs) > 0: - embeddings = encoder.embed_documents(docs) - return points_generator(ids, metadata, embeddings, vector_name) + embeddings = encoder_dense.embed_documents(docs) + sparse_embeddings = encoder_sparse.embed_documents(docs) + return points_generator( + ids, + metadata, + dense_encoded_docs=embeddings, + sparse_encoded_docs=sparse_embeddings, + ) return None def update_learning_resource_payload(serialized_document): - points = [vector_point_id(serialized_document["readable_id"])] + points = [vector_point_id(vector_point_key(serialized_document))] _set_payload( points, serialized_document, @@ -371,7 +405,7 @@ def should_generate_resource_embeddings(serialized_document): Determine if we should generate embeddings for a learning resource """ client = qdrant_client() - point_id = vector_point_id(serialized_document["readable_id"]) + point_id = vector_point_id(vector_point_key(serialized_document)) response = client.retrieve( collection_name=RESOURCES_COLLECTION_NAME, ids=[point_id], @@ -399,9 +433,9 @@ def should_generate_content_embeddings( if not point_id: # we just need metadata from the first chunk point_id = vector_point_id( - f"{serialized_document['resource_readable_id']}." - f"{serialized_document.get('run_readable_id', '')}." - f"{serialized_document['key']}.0" + vector_point_key( + serialized_document, chunk_number=0, document_type="content_file" + ) ) response = client.retrieve( collection_name=CONTENT_FILES_COLLECTION_NAME, @@ -419,14 +453,13 @@ def _embed_course_metadata_as_contentfile(serialized_resources): Embed general course info as a document in the contentfile collection """ client = qdrant_client() - encoder = dense_encoder() - vector_name = encoder.model_short_name() + encoder_dense = dense_encoder() + encoder_sparse = sparse_encoder() metadata = [] ids = [] docs = [] for doc in serialized_resources: - readable_id = doc["readable_id"] - resource_vector_point_id = str(vector_point_id(readable_id)) + resource_vector_point_id = str(vector_point_id(vector_point_key(doc))) serializer = LearningResourceMetadataDisplaySerializer(doc) serialized_document = serializer.render_document() checksum = checksum_for_content(str(serialized_document)) @@ -434,7 +467,7 @@ def _embed_course_metadata_as_contentfile(serialized_resources): serialized_document["checksum"] = checksum serialized_document["key"] = key document_point_id = vector_point_id( - f"{doc['readable_id']}.course_information.0" + vector_point_key(doc, document_type="course_information") ) if not should_generate_content_embeddings( serialized_document, document_point_id @@ -462,7 +495,11 @@ def _embed_course_metadata_as_contentfile(serialized_resources): ] split_ids = [ vector_point_id( - f"{doc['readable_id']}.course_information.{md['chunk_number']}" + vector_point_key( + doc, + document_type="course_information", + chunk_number=md["chunk_number"], + ) ) for md in split_metadatas ] @@ -470,8 +507,14 @@ def _embed_course_metadata_as_contentfile(serialized_resources): docs.extend(split_texts) ids.extend(split_ids) if len(docs) > 0: - embeddings = encoder.embed_documents(docs) - points = points_generator(ids, metadata, embeddings, vector_name) + embeddings = encoder_dense.embed_documents(docs) + sparse_embeddings = encoder_sparse.embed_documents(docs) + points = points_generator( + ids, + metadata, + dense_encoded_docs=embeddings, + sparse_encoded_docs=sparse_embeddings, + ) client.upload_points(CONTENT_FILES_COLLECTION_NAME, points=points, wait=False) @@ -479,8 +522,8 @@ def _generate_content_file_points(serialized_content): """ Chunk and embed content file documents, yielding PointStructs """ - encoder = dense_encoder() - vector_name = encoder.model_short_name() + encoder_dense = dense_encoder() + encoder_sparse = sparse_encoder() """ Break up requests according to chunk size to stay under openai limits @@ -520,7 +563,7 @@ def _generate_content_file_points(serialized_content): remove_params, collection_name=CONTENT_FILES_COLLECTION_NAME ) - split_docs = _chunk_documents(encoder, [embedding_context], [doc]) + split_docs = _chunk_documents(encoder_dense, [embedding_context], [doc]) # Identify non-empty chunks and their original indices valid_chunks = [(i, d) for i, d in enumerate(split_docs) if d.page_content] @@ -529,13 +572,14 @@ def _generate_content_file_points(serialized_content): continue split_texts = [d.page_content for _, d in valid_chunks] - resource_vector_point_id = vector_point_id(doc["resource_readable_id"]) + resource_vector_point_id = vector_point_id(vector_point_key(doc)) for i in range(0, len(split_texts), request_chunk_size): chunk_texts = split_texts[i : i + request_chunk_size] - chunk_embeddings = encoder.embed_documents(chunk_texts) + dense_chunk_embeddings = encoder_dense.embed_documents(chunk_texts) + sparse_chunk_embeddings = encoder_sparse.embed_documents(chunk_texts) - for j, embedding in enumerate(chunk_embeddings): + for j, dense_embedding in enumerate(dense_chunk_embeddings): # Map back to the original valid_chunk index relative_index = i + j if relative_index >= len(valid_chunks): @@ -554,17 +598,23 @@ def _generate_content_file_points(serialized_content): } point_id = vector_point_id( - f"{doc['resource_readable_id']}." - f"{doc.get('run_readable_id', '')}." - f"{doc['key']}.{chunk_id}" + vector_point_key( + doc, chunk_number=chunk_id, document_type="content_file" + ) ) yield models.PointStruct( - id=point_id, payload=metadata, vector={vector_name: embedding} + id=point_id, + payload=metadata, + vector={ + encoder_dense.model_short_name(): dense_embedding, + encoder_sparse.model_short_name(): sparse_chunk_embeddings[j], + }, ) # Explicitly free memory for large chunks del chunk_texts - del chunk_embeddings + del dense_chunk_embeddings + del sparse_chunk_embeddings gc.collect() @@ -588,7 +638,7 @@ def embed_learning_resources(ids, resource_type, overwrite): # noqa: PLR0915, C if resource_type != CONTENT_FILE_TYPE: serialized_resources = list(serialize_bulk_learning_resources(ids)) points = [ - (vector_point_id(serialized["readable_id"]), serialized) + (vector_point_id(vector_point_key(serialized)), serialized) for serialized in serialized_resources ] if not overwrite: @@ -622,9 +672,9 @@ def process_batch(docs_batch, summaries_list): contentfile_points = [ ( vector_point_id( - f"{doc['resource_readable_id']}." - f"{doc.get('run_readable_id', '')}." - f"{doc['key']}.0" + vector_point_key( + doc, chunk_number=0, document_type="content_file" + ) ), doc, ) @@ -782,12 +832,53 @@ def _merge_dicts(dicts): return result -def vector_search( +def vector_point_key( + serialized_document, chunk_number=0, document_type="learning_resource" +): + """ + Generate a consistent unique id for a vector point based on the document type + + Args: + serialized_document (dict): The serialized document + to generate the key for + chunk_number (int): The chunk number for content files + document_type (str): The type of document + Returns: + str: A unique key for the vector point + """ + platform = (serialized_document.get("platform") or {}).get("code", "") + if document_type == "learning_resource": + readable_id = serialized_document.get("readable_id") or serialized_document.get( + "resource_readable_id" + ) + return f"{platform}.{readable_id}" + elif document_type == "course_information": + return ( + f"{platform}." + f"{serialized_document['readable_id']}." + f"course_information.{chunk_number}" + ) + elif document_type == "content_file": + return ( + f"{platform}." + f"{serialized_document['resource_readable_id']}." + f"{serialized_document.get('run_readable_id', '')}." + f"{serialized_document['key']}." + f"{chunk_number}" + ) + else: + msg = "Invalid document type for vector point key" + raise ValueError(msg) + + +def vector_search( # noqa: PLR0913 query_string: str, params: dict, limit: int = 10, offset: int = 10, search_collection=RESOURCES_COLLECTION_NAME, + *, + hybrid_search: bool = False, ): """ Perform a vector search given a query string @@ -805,18 +896,45 @@ def vector_search( """ client = qdrant_client() - encoder = dense_encoder() + encoder_dense = dense_encoder() + encoder_sparse = sparse_encoder() + search_filter = qdrant_query_conditions(params, collection_name=search_collection) + prefetch_multiplier = settings.VECTOR_HYBRID_SEARCH_PREFETCH_MULTIPLIER + prefetch_max_limit = settings.VECTOR_HYBRID_SEARCH_PREFETCH_MAX_LIMIT + prefetch_limit = min((offset + limit) * prefetch_multiplier, prefetch_max_limit) + if query_string: search_params = { "collection_name": search_collection, - "using": encoder.model_short_name(), - "query": encoder.embed_query(query_string), "query_filter": search_filter, - "search_params": models.SearchParams(indexed_only=True), + "with_vectors": False, + "with_payload": True, + "search_params": models.SearchParams(indexed_only=True, exact=False), "limit": limit, } + + if hybrid_search: + search_params["prefetch"] = [ + models.Prefetch( + query=encoder_sparse.embed(query_string), + using=encoder_sparse.model_short_name(), + limit=prefetch_limit, + ), + models.Prefetch( + query=encoder_dense.embed_query(query_string), # <-- dense vector + using=encoder_dense.model_short_name(), + limit=prefetch_limit, + ), + ] + search_params["query"] = models.FusionQuery(fusion=models.Fusion.RRF) + else: + # fallback to dense only search + search_params["using"] = encoder_dense.model_short_name() + search_params["query"] = encoder_dense.embed_query(query_string) + if "group_by" in params: + search_params.pop("search_params", None) search_params["group_by"] = params.get("group_by") search_params["group_size"] = params.get("group_size", 1) group_result = client.query_points_groups(**search_params) @@ -843,6 +961,7 @@ def vector_search( scroll_filter=search_filter, limit=limit, offset=offset, + with_vectors=False, )[0] if search_collection == RESOURCES_COLLECTION_NAME: @@ -852,7 +971,7 @@ def vector_search( count_result = client.count( collection_name=search_collection, count_filter=search_filter, - exact=True, + exact=False, ) return { diff --git a/vector_search/utils_test.py b/vector_search/utils_test.py index 38d8480647..66daaa2eed 100644 --- a/vector_search/utils_test.py +++ b/vector_search/utils_test.py @@ -32,7 +32,7 @@ QDRANT_RESOURCE_PARAM_MAP, RESOURCES_COLLECTION_NAME, ) -from vector_search.encoders.utils import dense_encoder +from vector_search.encoders.utils import dense_encoder, sparse_encoder from vector_search.utils import ( _chunk_documents, _embed_course_metadata_as_contentfile, @@ -50,6 +50,7 @@ vector_point_id, vector_search, ) +from vector_search.utils import qdrant_client as vector_qdrant_client pytestmark = pytest.mark.django_db @@ -80,7 +81,10 @@ def test_vector_point_id_used_for_embed(mocker, content_type): ) if content_type == "course": - point_ids = [vector_point_id(resource.readable_id) for resource in resources] + point_ids = [ + vector_point_id(f"{resource.platform.code}.{resource.readable_id}") + for resource in resources + ] assert sorted( [ p.id @@ -92,7 +96,7 @@ def test_vector_point_id_used_for_embed(mocker, content_type): else: point_ids = [ vector_point_id( - f"{resource['resource_readable_id']}.{resource['run_readable_id']}.{resource['key']}.0" + f"{resource['platform']['code']}.{resource['resource_readable_id']}.{resource['run_readable_id']}.{resource['key']}.0" ) for resource in serialize_bulk_content_files([r.id for r in resources]) ] @@ -122,7 +126,10 @@ def test_embed_learning_resources_no_overwrite(mocker, content_type): # filter out 3 resources that are already embedded mocker.patch( "vector_search.utils.filter_existing_qdrant_points_by_ids", - return_value=[vector_point_id(r.readable_id) for r in resources[0:2]], + return_value=[ + vector_point_id(f"{r.platform.code}.{r.readable_id}") + for r in resources[0:2] + ], ) else: # all contentfiles exist in qdrant @@ -130,7 +137,7 @@ def test_embed_learning_resources_no_overwrite(mocker, content_type): "vector_search.utils.filter_existing_qdrant_points_by_ids", return_value=[ vector_point_id( - f"{doc['resource_readable_id']}.{doc['run_readable_id']}.{doc['key']}.0" + f"{doc['platform']['code']}.{doc['resource_readable_id']}.{doc['run_readable_id']}.{doc['key']}.0" ) for doc in serialize_bulk_content_files([r.id for r in resources[0:3]]) ], @@ -621,7 +628,9 @@ def test_update_payload_learning_resource(mocker): call_args = mock_qdrant.set_payload.call_args[1] assert call_args["collection_name"] == RESOURCES_COLLECTION_NAME assert call_args["points"] == [ - vector_point_id(serialized_resources[0]["readable_id"]) + vector_point_id( + f"{serialized_resources[0]['platform']['code']}.{serialized_resources[0]['readable_id']}" + ) ] # Verify payload contains the mapped values for src_key, dest_key in QDRANT_RESOURCE_PARAM_MAP.items(): @@ -648,6 +657,7 @@ def test_update_payload_content_file(mocker): call_args = mock_qdrant.set_payload.call_args[1] assert call_args["collection_name"] == CONTENT_FILES_COLLECTION_NAME assert call_args["points"] == ["test-point-id"] + # Verify payload contains the mapped values for src_key, dest_key in QDRANT_CONTENT_FILE_PARAM_MAP.items(): if src_key in serialized_files[0]: @@ -1083,3 +1093,138 @@ def test_set_payload_batched(mocker): # Check third batch call3_kwargs = mock_client.set_payload.mock_calls[2].kwargs assert call3_kwargs["points"] == ["point_4"] + + +def test_qdrant_cloud_inference_client(mocker, settings): + """ + Test that cloud inferencing is enabled in the qdrant client + if one of the encoders requires it + """ + # Patch the QdrantClient symbol used inside vector_search.utils + mock_qdrant_client_cls = mocker.patch("vector_search.utils.QdrantClient") + settings.QDRANT_SPARSE_ENCODER = ( + "vector_search.encoders.qdrant_cloud.QdrantCloudEncoder" + ) + sparse_encoder.cache_clear() + dense_encoder.cache_clear() + vector_qdrant_client.cache_clear() + vector_qdrant_client() + # Verify that cloud inference is enabled when using the cloud encoder + first_call_kwargs = mock_qdrant_client_cls.call_args.kwargs + assert first_call_kwargs.get("cloud_inference") is True + + # Switch to a non-cloud encoder and verify cloud inference is disabled + settings.QDRANT_SPARSE_ENCODER = ( + "vector_search.encoders.sparse_hash.SparseHashEncoder" + ) + mock_qdrant_client_cls.reset_mock() + vector_qdrant_client.cache_clear() + sparse_encoder.cache_clear() + dense_encoder.cache_clear() + vector_qdrant_client() + second_call_kwargs = mock_qdrant_client_cls.call_args.kwargs + assert second_call_kwargs.get("cloud_inference", False) is False + + +def test_vector_search_hybrid(mocker): + """ + Test that vector_search with hybrid_search=True searches using sparse and dense vectors + """ + mocker.patch("qdrant_client.QdrantClient") + mock_qdrant = mocker.patch("vector_search.utils.qdrant_client")() + mock_dense_encoder = mocker.patch("vector_search.utils.dense_encoder")() + mock_sparse_encoder = mocker.patch("vector_search.utils.sparse_encoder")() + + mock_dense_encoder.embed_query.return_value = [0.1, 0.2, 0.3] + mock_dense_encoder.model_short_name.return_value = "dense-test-encoder" + + # Sparse encoder expects dict like {"indices": [...], "values": [...]} for SparseVector kwargs + mock_sparse_encoder.embed.return_value = {"indices": [1, 2], "values": [0.5, 0.6]} + mock_sparse_encoder.model_short_name.return_value = "sparse-test-encoder" + + mocker.patch("vector_search.utils._resource_vector_hits", return_value=[]) + + mock_search_result = mocker.MagicMock() + mock_search_result.points = [] + mock_qdrant.query_points.return_value = mock_search_result + mock_qdrant.count.return_value = models.CountResult(count=0) + + from vector_search.utils import RESOURCES_COLLECTION_NAME, vector_search + + vector_search( + "test hybrid query", + {}, + search_collection=RESOURCES_COLLECTION_NAME, + hybrid_search=True, + ) + + mock_qdrant.query_points.assert_called_once() + call_args = mock_qdrant.query_points.call_args.kwargs + + assert isinstance(call_args["query"], models.FusionQuery) + assert call_args["query"].fusion == models.Fusion.RRF + + prefetches = call_args["prefetch"] + assert len(prefetches) == 2 + + sparse_prefetch = prefetches[0] + dense_prefetch = prefetches[1] + + assert sparse_prefetch.using == "sparse-test-encoder" + assert isinstance(sparse_prefetch.query, models.SparseVector) + assert sparse_prefetch.query.indices == [1, 2] + assert sparse_prefetch.query.values == [0.5, 0.6] + + assert dense_prefetch.using == "dense-test-encoder" + assert dense_prefetch.query == [0.1, 0.2, 0.3] + + +@pytest.mark.parametrize("use_group_by", [True, False]) +def test_vector_search_group_by_offset_behavior(mocker, use_group_by): + """ + Test that vector_search passes 'offset' to query_points when no group_by is provided, + and drops 'offset' and calls query_points_groups when group_by is provided. + """ + mocker.patch("qdrant_client.QdrantClient") + mock_qdrant = mocker.patch("vector_search.utils.qdrant_client")() + mock_encoder = mocker.patch("vector_search.utils.dense_encoder")() + mock_encoder.embed_query.return_value = [0.1, 0.2, 0.3] + mock_encoder.model_short_name.return_value = "test-encoder" + + mock_group_result = mocker.MagicMock() + mock_group_result.groups = [] + mock_qdrant.query_points_groups.return_value = mock_group_result + + mock_search_result = mocker.MagicMock() + mock_search_result.points = [] + mock_qdrant.query_points.return_value = mock_search_result + + mock_qdrant.count.return_value = models.CountResult(count=0) + + mocker.patch("vector_search.utils._content_file_vector_hits", return_value=[]) + + from vector_search.utils import CONTENT_FILES_COLLECTION_NAME, vector_search + + params = {} + if use_group_by: + params["group_by"] = "resource_readable_id" + + vector_search( + "test query", + params, + offset=15, + search_collection=CONTENT_FILES_COLLECTION_NAME, + ) + + if use_group_by: + mock_qdrant.query_points_groups.assert_called_once() + mock_qdrant.query_points.assert_not_called() + call_args = mock_qdrant.query_points_groups.call_args.kwargs + assert "offset" not in call_args + assert call_args.get("group_by") == "resource_readable_id" + else: + mock_qdrant.query_points.assert_called_once() + mock_qdrant.query_points_groups.assert_not_called() + call_args = mock_qdrant.query_points.call_args.kwargs + assert call_args.get("offset") == 15 + assert "group_by" not in call_args diff --git a/vector_search/views.py b/vector_search/views.py index 79eadc052c..6369b0e466 100644 --- a/vector_search/views.py +++ b/vector_search/views.py @@ -67,10 +67,15 @@ def get(self, request): if request_data.is_valid(): query_text = request_data.data.get("q", "") + hybrid_search = request_data.data.get("hybrid_search", False) limit = request_data.data.get("limit", 10) offset = request_data.data.get("offset", 0) response = vector_search( - query_text, limit=limit, offset=offset, params=request_data.data + query_text, + limit=limit, + offset=offset, + params=request_data.data, + hybrid_search=hybrid_search, ) if request_data.data.get("dev_mode"): return Response(response) @@ -127,6 +132,7 @@ def get(self, request): if request_data.is_valid(): query_text = request_data.data.get("q", "") + hybrid_search = request_data.data.get("hybrid_search", False) limit = request_data.data.get("limit", 10) offset = request_data.data.get("offset", 0) collection_name_override = request_data.data.get("collection_name") @@ -142,6 +148,7 @@ def get(self, request): offset=offset, params=request_data.data, search_collection=collection_name, + hybrid_search=hybrid_search, ) if request_data.data.get("dev_mode"): return Response(response) From 2c603aa4b8b95539644475e6980bc42bdce977e5 Mon Sep 17 00:00:00 2001 From: Chris Chudzicki Date: Wed, 25 Mar 2026 10:20:40 -0400 Subject: [PATCH 4/7] hide mitxonline logo and offered by (#3097) --- .../InfoSection.test.tsx | 56 ++++++++++++++++++- .../LearningResourceExpanded/InfoSection.tsx | 9 +++ .../src/components/Logo/Logo.tsx | 4 +- 3 files changed, 67 insertions(+), 2 deletions(-) diff --git a/frontends/main/src/page-components/LearningResourceExpanded/InfoSection.test.tsx b/frontends/main/src/page-components/LearningResourceExpanded/InfoSection.test.tsx index d4ca4c5aca..d00bcada0c 100644 --- a/frontends/main/src/page-components/LearningResourceExpanded/InfoSection.test.tsx +++ b/frontends/main/src/page-components/LearningResourceExpanded/InfoSection.test.tsx @@ -6,8 +6,14 @@ import { formatRunDate } from "ol-utilities" import invariant from "tiny-invariant" import user from "@testing-library/user-event" import { renderWithTheme } from "../../test-utils" -import { AvailabilityEnum, ResourceTypeEnum } from "api" +import { + AvailabilityEnum, + LearningResourcePlatform, + PlatformEnum, + ResourceTypeEnum, +} from "api" import { factories } from "api/test-utils" +import { faker } from "@faker-js/faker/locale/en" // This is a pipe followed by a zero-width space const SEPARATOR = "|​" @@ -446,3 +452,51 @@ describe("Learning resource info section parent course", () => { expect(within(section).queryByText("Parent Course:")).toBeNull() }) }) + +describe("Offered by section", () => { + const OFFERED_BY_LABEL = "Offered By:" + test("Shows offered by information when it exists", () => { + const platform: LearningResourcePlatform = { + code: faker.lorem.slug(), + name: faker.lorem.words(), + } + const resource = factories.learningResources.resource({ + resource_type: ResourceTypeEnum.Course, + platform, + }) + + renderWithTheme() + + invariant(resource.offered_by) + const section = screen.getByTestId("drawer-info-items") + within(section).getByText(OFFERED_BY_LABEL) + within(section).getByText(resource.offered_by.name) + }) + + test("Does not show offered by information when it is missing", () => { + const resource = factories.learningResources.resource({ + resource_type: ResourceTypeEnum.Document, + offered_by: null, + }) + + renderWithTheme() + + const section = screen.getByTestId("drawer-info-items") + expect(within(section).queryByText(OFFERED_BY_LABEL)).toBeNull() + }) + + test("Does not show offered by information when platform is MITxOnline", () => { + const resource = factories.learningResources.resource({ + resource_type: ResourceTypeEnum.Course, + platform: { + code: PlatformEnum.Mitxonline, + name: "MITx Online", + }, + }) + + renderWithTheme() + + const section = screen.getByTestId("drawer-info-items") + expect(within(section).queryByText(OFFERED_BY_LABEL)).toBeNull() + }) +}) diff --git a/frontends/main/src/page-components/LearningResourceExpanded/InfoSection.tsx b/frontends/main/src/page-components/LearningResourceExpanded/InfoSection.tsx index 695a0b2ba9..86a44262fe 100644 --- a/frontends/main/src/page-components/LearningResourceExpanded/InfoSection.tsx +++ b/frontends/main/src/page-components/LearningResourceExpanded/InfoSection.tsx @@ -23,6 +23,7 @@ import { CertificationTypeEnum, DeliveryEnum, LearningResource, + PlatformEnum, ResourceTypeEnum, } from "api" import { @@ -513,6 +514,14 @@ const INFO_ITEMS: InfoItemConfig = [ label: "Offered By:", Icon: RiVerifiedBadgeLine, selector: (resource: LearningResource) => { + if (resource.platform?.code === PlatformEnum.Mitxonline) { + /** + * Resources hosted on the MITxOnline platform are now branded as Learn. + * Since we are within the Learn site itself, we don't show offered by. + */ + return null + } + return resource.offered_by?.name || null }, }, diff --git a/frontends/ol-components/src/components/Logo/Logo.tsx b/frontends/ol-components/src/components/Logo/Logo.tsx index acc88df1e7..755d58628f 100644 --- a/frontends/ol-components/src/components/Logo/Logo.tsx +++ b/frontends/ol-components/src/components/Logo/Logo.tsx @@ -62,7 +62,9 @@ export const PLATFORM_LOGOS: Record = { image: "/images/platform_logos/edx.svg", aspect: 1.77, }, - [PlatformEnum.Mitxonline]: UNIT_LOGOS[OfferedByEnum.Mitx], + [PlatformEnum.Mitxonline]: { + name: "MITx Online", + }, [PlatformEnum.Bootcamps]: UNIT_LOGOS[OfferedByEnum.Bootcamps], [PlatformEnum.Xpro]: UNIT_LOGOS[OfferedByEnum.Xpro], [PlatformEnum.Podcast]: { From 65f748dda74d745ecb8a1d62a127c3a75658a6ab Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 25 Mar 2026 11:25:03 -0500 Subject: [PATCH 5/7] fix(deps): update dependency cairosvg to v2.9.0 [security] (#3052) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- pyproject.toml | 2 +- uv.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 8b56683661..c24834459d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,7 +16,7 @@ dependencies = [ "base36>=0.1.1,<0.2", "beautifulsoup4>=4.8.2,<5", "boto3>=1.26.155,<2", - "cairosvg==2.8.2", + "cairosvg==2.9.0", "celery>=5.3.1,<6", "celery-redbeat>=2.3.2,<3", "cffi>=2.0.0,<3", diff --git a/uv.lock b/uv.lock index c2bf6e89e8..fb7e36a45a 100644 --- a/uv.lock +++ b/uv.lock @@ -335,7 +335,7 @@ wheels = [ [[package]] name = "cairosvg" -version = "2.8.2" +version = "2.9.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cairocffi" }, @@ -344,9 +344,9 @@ dependencies = [ { name = "pillow" }, { name = "tinycss2" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ab/b9/5106168bd43d7cd8b7cc2a2ee465b385f14b63f4c092bb89eee2d48c8e67/cairosvg-2.8.2.tar.gz", hash = "sha256:07cbf4e86317b27a92318a4cac2a4bb37a5e9c1b8a27355d06874b22f85bef9f", size = 8398590, upload-time = "2025-05-15T06:56:32.653Z" } +sdist = { url = "https://files.pythonhosted.org/packages/38/07/e8412a13019b3f737972dea23a2c61ca42becafc16c9338f4ca7a0caa993/cairosvg-2.9.0.tar.gz", hash = "sha256:1debb00cd2da11350d8b6f5ceb739f1b539196d71d5cf5eb7363dbd1bfbc8dc5", size = 40877, upload-time = "2026-03-13T15:42:00.564Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/67/48/816bd4aaae93dbf9e408c58598bc32f4a8c65f4b86ab560864cb3ee60adb/cairosvg-2.8.2-py3-none-any.whl", hash = "sha256:eab46dad4674f33267a671dce39b64be245911c901c70d65d2b7b0821e852bf5", size = 45773, upload-time = "2025-05-15T06:56:28.552Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e0/5011747466414c12cac8a8df77aa235068669a6a5a5df301a96209db6054/cairosvg-2.9.0-py3-none-any.whl", hash = "sha256:4b82d07d145377dffdfc19d9791bd5fb65539bb4da0adecf0bdbd9cd4ffd7c68", size = 45962, upload-time = "2026-03-14T13:56:33.512Z" }, ] [[package]] @@ -2594,7 +2594,7 @@ requires-dist = [ { name = "base36", specifier = ">=0.1.1,<0.2" }, { name = "beautifulsoup4", specifier = ">=4.8.2,<5" }, { name = "boto3", specifier = ">=1.26.155,<2" }, - { name = "cairosvg", specifier = "==2.8.2" }, + { name = "cairosvg", specifier = "==2.9.0" }, { name = "celery", specifier = ">=5.3.1,<6" }, { name = "celery-redbeat", specifier = ">=2.3.2,<3" }, { name = "cffi", specifier = ">=2.0.0,<3" }, From ecf18085d469a0eb73f16f05c6c504761d291884 Mon Sep 17 00:00:00 2001 From: Chris Chudzicki Date: Wed, 25 Mar 2026 13:34:40 -0400 Subject: [PATCH 6/7] Fix verified enrollment for program-as-course hierarchy (#3100) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * bump client * bump client * fix verified program enrollment API call to match updated client The API changed from program_id path param to request_body array. Update DashboardCard and the enrollment test to use the new signature. Co-Authored-By: Claude Opus 4.6 (1M context) * add ancestorPrograms prop for verified enrollment across program hierarchy ModuleCard now accepts ancestorPrograms (array of {readable_id, enrollment_mode}) instead of a single programEnrollment. When any ancestor has verified enrollment_mode, it calls createVerifiedProgramEnrollment with all ancestor readable_ids. This enables verified enrollment for courses nested inside a program-as-course whose grandparent program has the verified enrollment. ProgramAsCourseCard passes ancestorPrograms through to ModuleCard. EnrollmentDisplay assembles the array: home dashboard passes the parent program-as-course; program dashboard passes both parent and grandparent. Co-Authored-By: Claude Opus 4.6 (1M context) * fix review issues: deduplicate render paths, move AncestorProgram type, fix legacy test - Extract renderResource() in EnrollmentExpandCollapse to eliminate duplicated map callback between shown/hidden resource lists. This also fixes the missing ancestorPrograms prop in the hidden resources path. - Move AncestorProgram type from ModuleCard to helpers.ts (shared domain concept, not card-specific). - Fix DashboardCard.test.tsx verified enrollment URL to match new API signature (courserun_id only, no program_id in path). Co-Authored-By: Claude Opus 4.6 (1M context) * remove unnecessary factory overrides in enrollment display test Use factory defaults instead of hardcoded titles and spreading throwaway factory instances. Assert on the factory-generated values instead. Co-Authored-By: Claude Opus 4.6 (1M context) * use EnrollmentModeEnum from API client instead of local redefinition Replace the hand-rolled EnrollmentMode const in ModuleCard with EnrollmentModeEnum from @mitodl/mitxonline-api-axios. Use the same type for AncestorProgram.enrollment_mode in helpers.ts for type safety. Co-Authored-By: Claude Opus 4.6 (1M context) * simplify ancestor program props: ProgramAsCourseCard assembles IDs and verified flag Replace ancestorPrograms array with a cleaner design: - ProgramAsCourseCard accepts optional ancestorProgramEnrollment (the grandparent enrollment, singular) and assembles parentProgramIds + useVerifiedEnrollment from courseProgram.readable_id + ancestor - ModuleCard accepts simple parentProgramIds (string[]) and useVerifiedEnrollment (boolean) — no enrollment objects needed - Remove AncestorProgram type from helpers.ts (no longer needed) This fixes the bug where ancestorPrograms was only populated when courseProgramEnrollment existed — the exact case we don't need it. Now the parent readable_id always comes from courseProgram (the program detail object), which exists regardless of enrollment status. Co-Authored-By: Claude Opus 4.6 (1M context) * add some docstrings, minor refactor * remove hardcoded IDs and titles from ProgramAsCourseCard tests Auto-generate program and module IDs inside setupCardData instead of requiring callers to pass arbitrary values. Assert on factory-generated values (cardData.courseProgram.title, cardData.moduleCourses[0].title) instead of hardcoded strings. Add docstring to setupCardData. Co-Authored-By: Claude Opus 4.6 (1M context) * simplify req_tree ordering test to reverse moduleCourses instead of req_tree The test verifies display order follows req_tree, not the moduleCourses array order. Reversing the moduleCourses input is simpler and more directly tests the behavior. Co-Authored-By: Claude Opus 4.6 (1M context) * improve test quality: add integration test, remove unnecessary overrides, use invariant - Add integration test for grandparent verified enrollment flowing through EnrollmentDisplay -> ProgramAsCourseCard -> ModuleCard, asserting both parent and grandparent readable_ids in POST body - Split verified enrollment test into two: regular course (one program ID) and program-as-course module (two program IDs) - Extract setupProgramDashboardVerifiedEnrollmentScenario helper (API setup only, no render) - Remove unnecessary grades: [] factory override in ProgramAsCourseCard tests - Remove hardcoded IDs and titles in EnrollmentDisplay verified enrollment test - Replace card\! non-null assertions with invariant() for clearer failure messages Co-Authored-By: Claude Opus 4.6 (1M context) * fix typo: Courslike -> Courselike in docstring Co-Authored-By: Claude Opus 4.6 (1M context) * add isVerifiedEnrollmentMode helper * use isVerifiedEnrollmentMode in legacy DashboardCard Replace local EnrollmentMode constant with the shared isVerifiedEnrollmentMode helper from @/common/mitxonline, consistent with ModuleCard and ProgramAsCourseCard. Co-Authored-By: Claude Opus 4.6 (1M context) * fix typo * update client * bump client * invalidate program enrollments, too --------- Co-authored-by: Claude Opus 4.6 (1M context) --- frontends/api/package.json | 2 +- .../src/mitxonline/hooks/enrollment/index.ts | 3 + .../api/src/mitxonline/test-utils/urls.ts | 4 +- frontends/main/package.json | 2 +- .../CoursewareDisplay/DashboardCard.test.tsx | 5 +- .../CoursewareDisplay/DashboardCard.tsx | 22 +- .../EnrollmentDisplay.test.tsx | 249 +++++++++++++----- .../CoursewareDisplay/EnrollmentDisplay.tsx | 173 +++++------- .../CoursewareDisplay/ModuleCard.tsx | 90 +++---- .../ProgramAsCourseCard.test.tsx | 192 +++++++++----- .../CoursewareDisplay/ProgramAsCourseCard.tsx | 50 +++- frontends/main/src/common/mitxonline.ts | 12 +- yarn.lock | 12 +- 13 files changed, 489 insertions(+), 327 deletions(-) diff --git a/frontends/api/package.json b/frontends/api/package.json index e7f2764d51..12319de795 100644 --- a/frontends/api/package.json +++ b/frontends/api/package.json @@ -29,7 +29,7 @@ "ol-test-utilities": "0.0.0" }, "dependencies": { - "@mitodl/mitxonline-api-axios": "^2026.3.24", + "@mitodl/mitxonline-api-axios": "^2026.3.25", "@tanstack/react-query": "^5.66.0", "axios": "^1.12.2", "tiny-invariant": "^1.3.3" diff --git a/frontends/api/src/mitxonline/hooks/enrollment/index.ts b/frontends/api/src/mitxonline/hooks/enrollment/index.ts index 22377c473b..97398dfef6 100644 --- a/frontends/api/src/mitxonline/hooks/enrollment/index.ts +++ b/frontends/api/src/mitxonline/hooks/enrollment/index.ts @@ -96,6 +96,9 @@ const useCreateVerifiedProgramEnrollment = () => { queryClient.invalidateQueries({ queryKey: enrollmentKeys.courseRunEnrollmentsList(), }) + queryClient.invalidateQueries({ + queryKey: enrollmentKeys.programEnrollmentsList(), + }) }, }) } diff --git a/frontends/api/src/mitxonline/test-utils/urls.ts b/frontends/api/src/mitxonline/test-utils/urls.ts index 9eee598272..06d1ff8df3 100644 --- a/frontends/api/src/mitxonline/test-utils/urls.ts +++ b/frontends/api/src/mitxonline/test-utils/urls.ts @@ -96,8 +96,8 @@ const baskets = { } const verifiedProgramEnrollments = { - create: (programId: string, courserunId: string) => - `${API_BASE_URL}/api/v2/verified_program_enrollments/${encodeURIComponent(programId)}/${encodeURIComponent(courserunId)}/`, + create: (courserunId: string) => + `${API_BASE_URL}/api/v2/verified_program_enrollments/${encodeURIComponent(courserunId)}/`, } export { diff --git a/frontends/main/package.json b/frontends/main/package.json index 6c910e0f1c..d59b526da7 100644 --- a/frontends/main/package.json +++ b/frontends/main/package.json @@ -14,7 +14,7 @@ "@emotion/styled": "^11.11.0", "@floating-ui/react": "^0.27.16", "@mitodl/course-search-utils": "^3.5.2", - "@mitodl/mitxonline-api-axios": "^2026.3.24", + "@mitodl/mitxonline-api-axios": "^2026.3.25", "@mitodl/smoot-design": "^6.24.0", "@mui/material": "^6.4.5", "@mui/material-nextjs": "^6.4.3", 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 49c0c662f3..d16e3dbeaa 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/DashboardCard.test.tsx +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/DashboardCard.test.tsx @@ -1209,10 +1209,7 @@ describe.each([ // Mock the enrollment endpoint const programEnrollmentEndpoint = - mitxonline.urls.verifiedProgramEnrollments.create( - programEnrollment.program.readable_id, - run.courseware_id, - ) + mitxonline.urls.verifiedProgramEnrollments.create(run.courseware_id) setMockResponse.post(programEnrollmentEndpoint, {}) renderWithProviders( diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/DashboardCard.tsx b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/DashboardCard.tsx index 2fe41742e3..9682d33109 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/DashboardCard.tsx +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/DashboardCard.tsx @@ -34,7 +34,10 @@ import { import { mitxUserQueries } from "api/mitxonline-hooks/user" import { useQuery } from "@tanstack/react-query" import { coursePageView, programPageView, programView } from "@/common/urls" -import { mitxonlineLegacyUrl } from "@/common/mitxonline" +import { + mitxonlineLegacyUrl, + isVerifiedEnrollmentMode, +} from "@/common/mitxonline" import { useReplaceBasketItem } from "api/mitxonline-hooks/baskets" import { EnrollmentStatus, getBestRun, getEnrollmentStatus } from "./helpers" import { @@ -45,12 +48,6 @@ import { } from "@mitodl/mitxonline-api-axios/v2" import CourseEnrollmentDialog from "@/page-components/EnrollmentDialogs/CourseEnrollmentDialog" -const EnrollmentMode = { - Audit: "audit", - Verified: "verified", -} as const -type EnrollmentMode = (typeof EnrollmentMode)[keyof typeof EnrollmentMode] - export const DashboardType = { Course: "course", CourseRunEnrollment: "courserun-enrollment", @@ -335,7 +332,7 @@ const useEnrollmentHandler = () => { return } createVerifiedProgramEnrollment.mutate( - { courserun_id: readableId, program_id: programCoursewareId }, + { courserun_id: readableId, request_body: [programCoursewareId] }, { onSuccess: () => { window.location.href = href @@ -688,15 +685,16 @@ const DashboardCard: React.FC = ({ const canUpgrade = isCourseRunEnrollment && - resource.data.enrollment_mode !== EnrollmentMode.Verified && + !isVerifiedEnrollmentMode(resource.data.enrollment_mode) && (enrollmentRun?.is_upgradable ?? false) && (enrollmentRun?.upgrade_product_is_active ?? false) // Handle enrollment click for courses const handleEnrollmentClick = React.useCallback(() => { if (isCourse) { - const isVerifiedProgramEnrollment = - programEnrollment?.enrollment_mode === EnrollmentMode.Verified + const isVerifiedProgramEnrollment = isVerifiedEnrollmentMode( + programEnrollment?.enrollment_mode, + ) enrollment.enroll({ course: resource.data, @@ -770,7 +768,7 @@ const DashboardCard: React.FC = ({ ) : null} {isCourseRunEnrollment && - resource.data.enrollment_mode !== EnrollmentMode.Verified && + !isVerifiedEnrollmentMode(resource.data.enrollment_mode) && offerUpgrade ? ( { const courseEnrollment = mitxonline.factories.enrollment.courseEnrollment({ b2b_contract_id: null, - run: { - ...mitxonline.factories.enrollment.courseEnrollment().run, - course: { - ...mitxonline.factories.enrollment.courseEnrollment().run.course, - title: "My Test Course", - }, - }, }) const programEnrollment = - mitxonline.factories.enrollment.programEnrollmentV3({ - program: { - ...mitxonline.factories.programs.simpleProgram(), - title: "My Test Program", - }, - }) + mitxonline.factories.enrollment.programEnrollmentV3() mockedUseFeatureFlagEnabled.mockReturnValue(true) setMockResponse.get(mitxonline.urls.enrollment.enrollmentsListV3(), [ @@ -271,11 +260,11 @@ describe("EnrollmentDisplay", () => { // Course title appears in desktop + mobile cards expect( - (await screen.findAllByText("My Test Course")).length, + (await screen.findAllByText(courseEnrollment.run.course.title)).length, ).toBeGreaterThan(0) // Program title appears in desktop + mobile cards expect( - (await screen.findAllByText("My Test Program")).length, + (await screen.findAllByText(programEnrollment.program.title)).length, ).toBeGreaterThan(0) }) @@ -1318,9 +1307,9 @@ describe("EnrollmentDisplay", () => { const cards = screen.getAllByTestId("enrollment-card-desktop") const card = cards.find((c) => within(c).queryByText("Clickable Course")) - expect(card).toBeDefined() + invariant(card, "Expected to find a card containing 'Clickable Course'") - const startButton = within(card!).getByTestId("courseware-button") + const startButton = within(card).getByTestId("courseware-button") await user.click(startButton) // Should open CourseEnrollmentDialog @@ -1441,84 +1430,200 @@ describe("EnrollmentDisplay", () => { expect(screen.queryByText("Requirements")).not.toBeInTheDocument() }) - test("Clicking 'Start Course' in verified program does one-click enrollment", async () => { + /** + * Sets up a verified program dashboard scenario with: + * - A parent program with a verified enrollment + * - A direct child course (regular requirement) + * - A child program-as-course with its own module course + * + * Mocks all API responses. Does not render — callers handle rendering + * and assertions. + */ + const setupProgramDashboardVerifiedEnrollmentScenario = () => { const mitxOnlineUser = mitxonline.factories.user.user() setMockResponse.get(mitxonline.urls.userMe.get(), mitxOnlineUser) - const reqTree = - new mitxonline.factories.requirements.RequirementTreeBuilder() - const requirements = reqTree.addOperator({ - operator: "all_of", - title: "Program Requirements", + // Child course (direct requirement of the parent program) + const childCourseRun = mitxonline.factories.courses.courseRun({ + b2b_contract: null, + is_enrollable: true, + courseware_url: faker.internet.url(), }) - requirements.addCourse({ course: 1 }) - - const program = mitxonline.factories.programs.program({ - id: 888, - courses: [1], - req_tree: reqTree.serialize(), + const childCourse = mitxonline.factories.courses.course({ + courseruns: [childCourseRun], + next_run_id: childCourseRun.id, }) - const run = mitxonline.factories.courses.courseRun({ + // Module course (child of the program-as-course) + const moduleRun = mitxonline.factories.courses.courseRun({ b2b_contract: null, is_enrollable: true, courseware_url: faker.internet.url(), }) + const moduleCourse = mitxonline.factories.courses.course({ + courseruns: [moduleRun], + next_run_id: moduleRun.id, + }) - const courses = { - count: 1, - next: null, - previous: null, - results: [ - mitxonline.factories.courses.course({ - id: 1, - title: "Test Course", - courseruns: [run], - next_run_id: run.id, - }), - ], - } + // Program-as-course (child requirement of the parent program) + const programAsCourseReqTree = + new mitxonline.factories.requirements.RequirementTreeBuilder() + const moduleSection = programAsCourseReqTree.addOperator({ + operator: "all_of", + title: "Modules", + }) + moduleSection.addCourse({ course: moduleCourse.id }) + + const programAsCourse = mitxonline.factories.programs.program({ + display_mode: "course", + courses: [moduleCourse.id], + req_tree: programAsCourseReqTree.serialize(), + }) - const programEnrollment = + // Parent program with both a course and a program-as-course + const parentReqTree = + new mitxonline.factories.requirements.RequirementTreeBuilder() + const parentRequirements = parentReqTree.addOperator({ + operator: "all_of", + title: "Program Requirements", + }) + parentRequirements.addCourse({ course: childCourse.id }) + parentRequirements.addProgram({ program: programAsCourse.id }) + + const parentProgram = mitxonline.factories.programs.program({ + courses: [childCourse.id], + req_tree: parentReqTree.serialize(), + }) + + const parentProgramEnrollment = mitxonline.factories.enrollment.programEnrollmentV3({ - enrollment_mode: "verified", // Verified program enrollment + enrollment_mode: "verified", program: { - id: program.id, - title: program.title, - live: program.live, - program_type: program.program_type, - readable_id: program.readable_id, + id: parentProgram.id, + title: parentProgram.title, + live: parentProgram.live, + program_type: parentProgram.program_type, + readable_id: parentProgram.readable_id, }, }) mockedUseFeatureFlagEnabled.mockReturnValue(true) - setMockResponse.get(mitxonline.urls.enrollment.enrollmentsListV3(), []) // No course enrollments yet + setMockResponse.get(mitxonline.urls.enrollment.enrollmentsListV3(), []) setMockResponse.get( mitxonline.urls.programEnrollments.enrollmentsListV3(), - [programEnrollment], + [parentProgramEnrollment], + ) + setMockResponse.get( + mitxonline.urls.programs.programDetail(parentProgram.id), + parentProgram, ) - setMockResponse.get(mitxonline.urls.programs.programDetail(888), program) setMockResponse.get( mitxonline.urls.courses.coursesList({ - id: program.courses, - page_size: program.courses.length, + id: parentProgram.courses, + page_size: parentProgram.courses.length, }), - courses, + { count: 1, next: null, previous: null, results: [childCourse] }, + ) + setMockResponse.get( + mitxonline.urls.programs.programsList({ + id: [programAsCourse.id], + page_size: 1, + }), + { + count: 1, + next: null, + previous: null, + results: [programAsCourse], + }, + ) + setMockResponse.get( + mitxonline.urls.courses.coursesList({ + id: [moduleCourse.id], + page_size: 1, + }), + { count: 1, next: null, previous: null, results: [moduleCourse] }, ) - // Mock the enrollment endpoint - const programEnrollmentEndpoint = + // Mock verified enrollment endpoints for both course runs + const childCourseEnrollmentEndpoint = mitxonline.urls.verifiedProgramEnrollments.create( - programEnrollment.program.readable_id, - run.courseware_id, + childCourseRun.courseware_id, ) - setMockResponse.post(programEnrollmentEndpoint, {}) + setMockResponse.post(childCourseEnrollmentEndpoint, {}) - renderWithProviders() + const moduleCourseEnrollmentEndpoint = + mitxonline.urls.verifiedProgramEnrollments.create( + moduleRun.courseware_id, + ) + setMockResponse.post(moduleCourseEnrollmentEndpoint, {}) + + return { + parentProgram, + parentProgramEnrollment, + childCourse, + childCourseRun, + childCourseEnrollmentEndpoint, + programAsCourse, + moduleCourse, + moduleRun, + moduleCourseEnrollmentEndpoint, + } + } + + test("Clicking 'Start Course' on a regular course in a verified program does one-click enrollment", async () => { + const { + parentProgram, + parentProgramEnrollment, + childCourse, + childCourseEnrollmentEndpoint, + } = setupProgramDashboardVerifiedEnrollmentScenario() + + renderWithProviders() await screen.findByText("Program Requirements") + await waitFor( + () => { + const skeletons = screen.queryAllByTestId("skeleton") + expect(skeletons).toHaveLength(0) + }, + { timeout: 3000 }, + ) + + const cards = screen.getAllByTestId("enrollment-card-desktop") + const card = cards.find((c) => within(c).queryByText(childCourse.title)) + invariant( + card, + `Expected to find a card containing "${childCourse.title}"`, + ) + + const startButton = within(card).getByTestId("courseware-button") + await user.click(startButton) + + await waitFor(() => { + expect(mockAxiosInstance.request).toHaveBeenCalledWith( + expect.objectContaining({ + method: "POST", + url: childCourseEnrollmentEndpoint, + data: JSON.stringify([parentProgramEnrollment.program.readable_id]), + }), + ) + }) - // Wait for the card to load + expect(screen.queryByRole("dialog")).not.toBeInTheDocument() + }) + + test("Clicking 'Start Course' on a module in a program-as-course sends both parent and grandparent program IDs", async () => { + const { + parentProgram, + parentProgramEnrollment, + programAsCourse, + moduleCourse, + moduleCourseEnrollmentEndpoint, + } = setupProgramDashboardVerifiedEnrollmentScenario() + + renderWithProviders() + + await screen.findByText("Program Requirements") await waitFor( () => { const skeletons = screen.queryAllByTestId("skeleton") @@ -1527,25 +1632,29 @@ describe("EnrollmentDisplay", () => { { timeout: 3000 }, ) - // Find the card and click the button const cards = screen.getAllByTestId("enrollment-card-desktop") - const card = cards.find((c) => within(c).queryByText("Test Course")) - expect(card).toBeDefined() + const card = cards.find((c) => within(c).queryByText(moduleCourse.title)) + invariant( + card, + `Expected to find a card containing "${moduleCourse.title}"`, + ) - const startButton = within(card!).getByTestId("courseware-button") + const startButton = within(card).getByTestId("courseware-button") await user.click(startButton) - // Should call enrollment endpoint (not open dialog) await waitFor(() => { expect(mockAxiosInstance.request).toHaveBeenCalledWith( expect.objectContaining({ method: "POST", - url: programEnrollmentEndpoint, + url: moduleCourseEnrollmentEndpoint, + data: JSON.stringify([ + programAsCourse.readable_id, + parentProgramEnrollment.program.readable_id, + ]), }), ) }) - // Dialog should NOT appear expect(screen.queryByRole("dialog")).not.toBeInTheDocument() }) diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/EnrollmentDisplay.tsx b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/EnrollmentDisplay.tsx index bd0e622bcb..2d23234a4f 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/EnrollmentDisplay.tsx +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/EnrollmentDisplay.tsx @@ -180,6 +180,7 @@ const isProgramAsCourseEnrollment = ( type ProgramAsCourseProgramData = { id: number + readable_id: string title?: string | null start_date?: string | null end_date?: string | null @@ -222,100 +223,58 @@ const EnrollmentExpandCollapse: React.FC = ({ ? maybeShown : maybeShown.slice(MIN_VISIBLE) - return ( - <> - - {shownResources.map((resource) => { - if (isProgramAsCourseEnrollment(resource)) { - const courseProgram = courseProgramsById.get( - resource.data.program.id, - ) - if (!courseProgram) { - return ( - - ) - } + const renderResource = (resource: DashboardResource) => { + if (isProgramAsCourseEnrollment(resource)) { + const courseProgram = courseProgramsById.get(resource.data.program.id) + if (!courseProgram) { + return ( + + ) + } - return ( - - ) + return ( + + ) + } - return ( - - ) - })} + return ( + + ) + } + + return ( + <> + + {shownResources.map(renderResource)} {hiddenResources.length === 0 ? null : ( <> - {hiddenResources.map((resource) => { - if (isProgramAsCourseEnrollment(resource)) { - const courseProgram = courseProgramsById.get( - resource.data.program.id, - ) - if (!courseProgram) { - return ( - - ) - } - - return ( - - ) - } - - return ( - - ) - })} + {hiddenResources.map(renderResource)} @@ -712,6 +671,16 @@ const ProgramEnrollmentDisplay: React.FC = ({ } moduleEnrollmentsByCourseId={enrollmentsByCourseId} courseProgramEnrollment={item.courseProgramEnrollment} + ancestorProgramEnrollment={ + programEnrollment + ? { + readable_id: + programEnrollment.program.readable_id, + enrollment_mode: + programEnrollment.enrollment_mode, + } + : undefined + } /> ) } @@ -770,16 +739,12 @@ const AllEnrollmentsDisplay: React.FC = () => { ) }) ?? [] - const programAsCourseProgramIds = React.useMemo( - () => - filteredProgramEnrollments - .filter( - (enrollment) => - enrollment.program.display_mode === DisplayModeEnum.Course, - ) - .map((enrollment) => enrollment.program.id), - [filteredProgramEnrollments], - ) + const programAsCourseProgramIds = filteredProgramEnrollments + .filter( + (enrollment) => + enrollment.program.display_mode === DisplayModeEnum.Course, + ) + .map((enrollment) => enrollment.program.id) const { data: homeCoursePrograms, isLoading: homeCourseProgramsLoading } = useQuery({ @@ -790,15 +755,13 @@ const AllEnrollmentsDisplay: React.FC = () => { enabled: programAsCourseProgramIds.length > 0, }) - const homeCourseProgramModuleIds = React.useMemo(() => { - const uniqueIds = new Set() - ;(homeCoursePrograms?.results ?? []).forEach((courseProgram) => { - ;(courseProgram.courses ?? []).forEach((courseId) => - uniqueIds.add(courseId), - ) - }) - return [...uniqueIds] - }, [homeCoursePrograms?.results]) + const homeCourseProgramModuleIds = [ + ...new Set( + homeCoursePrograms?.results.flatMap( + (courseProgram) => getIdsFromReqTree(courseProgram.req_tree).courseIds, + ), + ), + ] const { data: homeCourseProgramModuleCourses, diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ModuleCard.tsx b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ModuleCard.tsx index be480f61ae..87291cbbe0 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ModuleCard.tsx +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ModuleCard.tsx @@ -30,17 +30,11 @@ import { EnrollmentStatus, getBestRun, getEnrollmentStatus } from "./helpers" import { CourseWithCourseRunsSerializerV2, CourseRunEnrollmentV3, - V3UserProgramEnrollment, CourseRunV2, + EnrollmentModeEnum, } from "@mitodl/mitxonline-api-axios/v2" import CourseEnrollmentDialog from "@/page-components/EnrollmentDialogs/CourseEnrollmentDialog" -const EnrollmentMode = { - Audit: "audit", - Verified: "verified", -} as const -type EnrollmentMode = (typeof EnrollmentMode)[keyof typeof EnrollmentMode] - export const DashboardType = { Course: "course", CourseRunEnrollment: "courserun-enrollment", @@ -266,28 +260,28 @@ const useEnrollmentHandler = () => { const enroll = React.useCallback( ({ + courseRun, course, - readableId, - href, isB2B, - isVerifiedProgram, - programCoursewareId, + useVerifiedEnrollment, + parentProgramIds, }: { + courseRun: CourseRunV2 course: CourseWithCourseRunsSerializerV2 - readableId?: string - href?: string isB2B?: boolean - isVerifiedProgram?: boolean - programCoursewareId?: string + useVerifiedEnrollment?: boolean + parentProgramIds?: string[] }) => { + const readableId = courseRun.courseware_id + const href = courseRun.courseware_url + if (!readableId || !href) { + console.warn("Cannot enroll: missing required data", { + readableId, + href, + }) + return + } if (isB2B) { - if (!readableId || !href) { - console.warn("Cannot enroll in B2B course: missing required data", { - readableId, - href, - }) - return - } const userCountry = mitxOnlineUser.data?.legal_address?.country const userYearOfBirth = mitxOnlineUser.data?.user_profile?.year_of_birth const showJustInTimeDialog = !userCountry || !userYearOfBirth @@ -307,16 +301,12 @@ const useEnrollmentHandler = () => { }, ) } - } else if (isVerifiedProgram && programCoursewareId && readableId) { - if (!href) { - console.warn( - "Cannot enroll in verified program course: missing href", - { href }, - ) - return - } + } else if (useVerifiedEnrollment && parentProgramIds?.length) { createVerifiedProgramEnrollment.mutate( - { courserun_id: readableId, program_id: programCoursewareId }, + { + courserun_id: readableId, + request_body: parentProgramIds, + }, { onSuccess: () => { window.location.href = href @@ -570,7 +560,8 @@ type DashboardCardProps = { className?: string variant?: "default" | "stacked" contractId?: number - programEnrollment?: V3UserProgramEnrollment + useVerifiedEnrollment?: boolean + parentProgramIds?: string[] onUpgradeError?: (error: string) => void } @@ -670,7 +661,8 @@ const DashboardCourseCard: React.FC = ({ className, variant = "default", contractId, - programEnrollment, + useVerifiedEnrollment, + parentProgramIds, onUpgradeError, }) => { const enrollment = useEnrollmentHandler() @@ -705,15 +697,9 @@ const DashboardCourseCard: React.FC = ({ const disableEnrollment = isCourse && !hasEnrollableRuns - const readableId = isCourse - ? courseRun?.courseware_id - : isCourseRunEnrollment - ? resource.data.run.courseware_id - : undefined - const canUpgrade = isCourseRunEnrollment && - resource.data.enrollment_mode !== EnrollmentMode.Verified && + resource.data.enrollment_mode !== EnrollmentModeEnum.Verified && (enrollmentRun?.is_upgradable ?? false) && (enrollmentRun?.upgrade_product_is_active ?? false) @@ -722,31 +708,23 @@ const DashboardCourseCard: React.FC = ({ const upgradeProductId = enrollmentRun?.upgrade_product_id const handleEnrollmentClick = React.useCallback(() => { - if (!isCourse) return - - const isVerifiedProgramEnrollment = - programEnrollment?.enrollment_mode === EnrollmentMode.Verified + if (!isCourse || !courseRun) return enrollment.enroll({ + courseRun, course: resource.data, - readableId, - href: buttonHref ?? coursewareUrl ?? undefined, isB2B: !!b2bContractId, - isVerifiedProgram: isVerifiedProgramEnrollment, - programCoursewareId: isVerifiedProgramEnrollment - ? programEnrollment?.program.readable_id - : undefined, + useVerifiedEnrollment, + parentProgramIds, }) }, [ b2bContractId, - buttonHref, - coursewareUrl, enrollment, isCourse, - programEnrollment?.enrollment_mode, - programEnrollment?.program.readable_id, - readableId, + useVerifiedEnrollment, + parentProgramIds, resource, + courseRun, ]) const titleHref = isCourseRunEnrollment ? (buttonHref ?? coursewareUrl) : null @@ -788,7 +766,7 @@ const DashboardCourseCard: React.FC = ({ const showUpgradeLink = isCourseRunEnrollment && - resource.data.enrollment_mode !== EnrollmentMode.Verified && + resource.data.enrollment_mode !== EnrollmentModeEnum.Verified && offerUpgrade const showCertificateSection = certificateLink || showUpgradeLink const startDate = courseRun?.start_date ?? enrollmentRun?.start_date diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ProgramAsCourseCard.test.tsx b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ProgramAsCourseCard.test.tsx index 961f1b9714..6d89ec398d 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ProgramAsCourseCard.test.tsx +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ProgramAsCourseCard.test.tsx @@ -5,60 +5,62 @@ import { setMockResponse, setupLocationMock, user, + within, } from "@/test-utils" +import { mockAxiosInstance } from "api/test-utils" import * as mitxonline from "api/mitxonline-test-utils" import { ProgramAsCourseCard } from "./ProgramAsCourseCard" +import { waitFor } from "@testing-library/react" +import invariant from "tiny-invariant" import moment from "moment" describe("ProgramAsCourseCard", () => { setupLocationMock() + /** + * Creates a ProgramAsCourseCard data set with: + * - A program with two module courses linked via req_tree + * - An enrollment in the first module (no grades, no certificate) + * - Optionally, a program enrollment for the courselike program + * - Mock response for the user endpoint + */ const setupCardData = ({ - programId, - includeProgramEnrollment, + includeProgramEnrollment = false, startDate, endDate, }: { - programId: number - includeProgramEnrollment: boolean + includeProgramEnrollment?: boolean startDate?: string | null endDate?: string | null - }) => { + } = {}) => { + const moduleOne = mitxonline.factories.courses.course({ + courseruns: [mitxonline.factories.courses.courseRun()], + }) + const moduleTwo = mitxonline.factories.courses.course({ + courseruns: [mitxonline.factories.courses.courseRun()], + }) + const reqTree = new mitxonline.factories.requirements.RequirementTreeBuilder() const modules = reqTree.addOperator({ operator: "all_of", title: "Modules", }) - modules.addCourse({ course: 1 }) - modules.addCourse({ course: 2 }) + modules.addCourse({ course: moduleOne.id }) + modules.addCourse({ course: moduleTwo.id }) const program = mitxonline.factories.programs.program({ - id: programId, - title: "Micro Program", - courses: [1, 2], + courses: [moduleOne.id, moduleTwo.id], req_tree: reqTree.serialize(), start_date: startDate ?? null, end_date: endDate ?? null, }) - const moduleOne = mitxonline.factories.courses.course({ - id: 1, - title: "Module One", - courseruns: [mitxonline.factories.courses.courseRun()], - }) - const moduleTwo = mitxonline.factories.courses.course({ - id: 2, - title: "Module Two", - courseruns: [mitxonline.factories.courses.courseRun()], - }) - const moduleEnrollment = mitxonline.factories.enrollment.courseEnrollment({ run: { ...moduleOne.courseruns[0], course: moduleOne, }, - grades: [], certificate: null, }) @@ -90,10 +92,7 @@ describe("ProgramAsCourseCard", () => { } test("renders modules and progress summary", async () => { - const cardData = setupCardData({ - programId: 301, - includeProgramEnrollment: true, - }) + const cardData = setupCardData({ includeProgramEnrollment: true }) renderWithProviders( { />, ) - await screen.findByText("Micro Program") + await screen.findByText(cardData.courseProgram.title) expect(screen.getByText("2 Modules (0 of 2 complete)")).toBeInTheDocument() - expect(screen.getAllByText("Module One").length).toBeGreaterThan(0) - expect(screen.getAllByText("Module Two").length).toBeGreaterThan(0) + expect( + screen.getAllByText(cardData.moduleCourses[0].title).length, + ).toBeGreaterThan(0) + expect( + screen.getAllByText(cardData.moduleCourses[1].title).length, + ).toBeGreaterThan(0) }) test("renders when user is not enrolled in the ProgramAsCourse", async () => { - const cardData = setupCardData({ - programId: 302, - includeProgramEnrollment: false, - }) + const cardData = setupCardData() renderWithProviders( { />, ) - await screen.findByText("Micro Program") + await screen.findByText(cardData.courseProgram.title) expect(screen.getByText("Not Started")).toBeInTheDocument() }) test("shows date popover content when date summary is clicked", async () => { const cardData = setupCardData({ - programId: 303, includeProgramEnrollment: true, startDate: moment().subtract(5, "days").toISOString(), endDate: moment().add(5, "days").toISOString(), @@ -152,50 +151,109 @@ describe("ProgramAsCourseCard", () => { expect(await screen.findByText("Important Dates:")).toBeInTheDocument() }) - test("renders module rows in req_tree order, not API result order", async () => { - const reqTree = - new mitxonline.factories.requirements.RequirementTreeBuilder() - const modules = reqTree.addOperator({ - operator: "all_of", - title: "Modules", - }) - modules.addCourse({ course: 2 }) - modules.addCourse({ course: 1 }) + test("displays module rows in req_tree order, irrespective of moduleCourses order", async () => { + const cardData = setupCardData() + const [moduleOne, moduleTwo] = cardData.moduleCourses - const moduleOne = mitxonline.factories.courses.course({ - id: 1, - title: "Module One", - courseruns: [mitxonline.factories.courses.courseRun()], + renderWithProviders( + , + ) + + await screen.findByText(cardData.courseProgram.title) + const rows = await screen.findAllByTestId("enrollment-card-desktop") + // req_tree has moduleOne first, moduleTwo second (from setupCardData) + expect(rows[0]).toHaveTextContent(moduleOne.title) + expect(rows[1]).toHaveTextContent(moduleTwo.title) + }) + + test("clicking 'Start Course' on an unenrolled module uses verified enrollment when ancestor has verified mode", async () => { + const cardData = setupCardData() + const [moduleOne] = cardData.moduleCourses + + // Create a run we control for the first module + const run = mitxonline.factories.courses.courseRun({ + is_enrollable: true, + courseware_url: "https://courses.example.com/run1", }) - const moduleTwo = mitxonline.factories.courses.course({ - id: 2, - title: "Module Two", - courseruns: [mitxonline.factories.courses.courseRun()], + const moduleWithRun = mitxonline.factories.courses.course({ + id: moduleOne.id, + courseruns: [run], + next_run_id: run.id, }) - const courseProgram = mitxonline.factories.programs.program({ - id: 304, - title: "Micro Program", - courses: [1, 2], - req_tree: reqTree.serialize(), - }) + const enrollmentEndpoint = + mitxonline.urls.verifiedProgramEnrollments.create(run.courseware_id) + setMockResponse.post(enrollmentEndpoint, {}) - setMockResponse.get( - mitxonline.urls.userMe.get(), - mitxonline.factories.user.user(), + renderWithProviders( + , + ) + + const cards = await screen.findAllByTestId("enrollment-card-desktop") + const card = cards.find((c) => within(c).queryByText(moduleWithRun.title)) + invariant( + card, + `Expected to find a card containing "${moduleWithRun.title}"`, ) + const startButton = within(card).getByTestId("courseware-button") + await user.click(startButton) + + await waitFor(() => { + expect(mockAxiosInstance.request).toHaveBeenCalledWith( + expect.objectContaining({ + method: "POST", + url: enrollmentEndpoint, + data: JSON.stringify([ + cardData.courseProgram.readable_id, + "grandparent-program", + ]), + }), + ) + }) + }) + + test("clicking 'Start Course' on an unenrolled module opens enrollment dialog when no ancestor is verified", async () => { + const cardData = setupCardData() + const [moduleOne] = cardData.moduleCourses + + const run = mitxonline.factories.courses.courseRun({ + is_enrollable: true, + }) + const moduleWithRun = mitxonline.factories.courses.course({ + id: moduleOne.id, + courseruns: [run], + next_run_id: run.id, + }) renderWithProviders( , ) - await screen.findByText("Micro Program") - const rows = await screen.findAllByTestId("enrollment-card-desktop") - expect(rows[0]).toHaveTextContent("Module Two") - expect(rows[1]).toHaveTextContent("Module One") + const cards = await screen.findAllByTestId("enrollment-card-desktop") + const card = cards.find((c) => within(c).queryByText(moduleWithRun.title)) + invariant( + card, + `Expected to find a card containing "${moduleWithRun.title}"`, + ) + const startButton = within(card).getByTestId("courseware-button") + await user.click(startButton) + + await screen.findByRole("dialog", { name: moduleWithRun.title }) }) }) diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ProgramAsCourseCard.tsx b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ProgramAsCourseCard.tsx index d13ef9caef..f3a4cf8d2c 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ProgramAsCourseCard.tsx +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ProgramAsCourseCard.tsx @@ -20,7 +20,10 @@ import { DashboardType as ModuleCardType, } from "./ModuleCard" import { formatDate } from "ol-utilities" -import { getIdsFromReqTree } from "@/common/mitxonline" +import { + getIdsFromReqTree, + isVerifiedEnrollmentMode, +} from "@/common/mitxonline" const ProgramCardRoot = styled.div(({ theme }) => ({ display: "flex", @@ -244,17 +247,47 @@ const getRelativeDateContent = ( } interface ProgramAsCourseCardProps { + /** + * The courselike program to display. + */ courseProgram: { id: number + readable_id: string title?: string | null start_date?: string | null end_date?: string | null courses?: number[] req_tree?: V2ProgramRequirement[] } + /** + * child courses of the program. These correspond to nodes in the req_tree. + */ moduleCourses: CourseWithCourseRunsSerializerV2[] + /** + * Enrollments in the child courses. These may or may not exist, depending on + * whether the user has started that course. + */ moduleEnrollmentsByCourseId: Record + /** + * Enrollment in the courselike program, if user has an enrollment in it. + */ courseProgramEnrollment?: V3UserProgramEnrollment + /** + * Additional ancestor program enrollments. + * + * This facilitates verified enrollments. For example: + * - Ancestor Program P1 + * - Courselike Program P1a + * - Child Course C1, etc... + * + * Initially, a user will have a verified enrollment in P1 but NOT P1a. + * We pass P1's enrollment as an ancestorProgramEnrollment. This allows us to + * request a verified enrollment in both C1 and P1a. + */ + ancestorProgramEnrollment?: { + readable_id: string + enrollment_mode?: string | null + } Component?: React.ElementType className?: string } @@ -278,6 +311,7 @@ const ProgramAsCourseCard: React.FC = ({ moduleCourses, moduleEnrollmentsByCourseId, courseProgramEnrollment, + ancestorProgramEnrollment, Component, className, }) => { @@ -339,6 +373,17 @@ const ProgramAsCourseCard: React.FC = ({ ) const showDatePopoverTrigger = Boolean(datePopoverContent) + const parentProgramIds = [ + courseProgram.readable_id, + ...(ancestorProgramEnrollment + ? [ancestorProgramEnrollment.readable_id] + : []), + ] + const useVerifiedEnrollment = [ + courseProgramEnrollment?.enrollment_mode, + ancestorProgramEnrollment?.enrollment_mode, + ].some(isVerifiedEnrollmentMode) + return ( = ({ runId: bestEnrollment?.run.id, })} resource={resource} - programEnrollment={courseProgramEnrollment} + useVerifiedEnrollment={useVerifiedEnrollment} + parentProgramIds={parentProgramIds} variant="stacked" /> ) diff --git a/frontends/main/src/common/mitxonline.ts b/frontends/main/src/common/mitxonline.ts index e18f9976f6..b66e525eb6 100644 --- a/frontends/main/src/common/mitxonline.ts +++ b/frontends/main/src/common/mitxonline.ts @@ -6,7 +6,11 @@ import type { ProductFlexiblePrice, V2ProgramRequirement, } from "@mitodl/mitxonline-api-axios/v2" -import { DiscountTypeEnum, NodeTypeEnum } from "@mitodl/mitxonline-api-axios/v2" +import { + DiscountTypeEnum, + EnrollmentModeEnum, + NodeTypeEnum, +} from "@mitodl/mitxonline-api-axios/v2" import invariant from "tiny-invariant" const NEXT_PUBLIC_MITX_ONLINE_LEGACY_BASE_URL = @@ -183,6 +187,11 @@ const getBestRun = ( if (contractId) runs = runs.filter((run) => run.b2b_contract === contractId) return runs.find((run) => run.id === course.next_run_id) ?? runs[0] } + +const isVerifiedEnrollmentMode = (mode?: string | null) => { + return mode === EnrollmentModeEnum.Verified +} + export { formatPrice, priceWithDiscount, @@ -192,5 +201,6 @@ export { getEnrollmentType, getIdsFromReqTree, getBestRun, + isVerifiedEnrollmentMode, } export type { PriceWithDiscount, EnrollmentType } diff --git a/yarn.lock b/yarn.lock index 1ba46b873d..2052d9db39 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3514,13 +3514,13 @@ __metadata: languageName: node linkType: hard -"@mitodl/mitxonline-api-axios@npm:^2026.3.24": - version: 2026.3.24 - resolution: "@mitodl/mitxonline-api-axios@npm:2026.3.24" +"@mitodl/mitxonline-api-axios@npm:^2026.3.25": + version: 2026.3.25 + resolution: "@mitodl/mitxonline-api-axios@npm:2026.3.25" dependencies: "@types/node": "npm:^20.11.19" axios: "npm:^1.6.5" - checksum: 10/aa3c320515a8436df8c16164dd37d96ee0a9900d2e6575e9d151326a5398be6e20dfadb899bfe0604551080da64cefa1158f63d520f333325dfb1f4024415c6e + checksum: 10/978e7e669c67778b0a6326072b57546d615ea5e02540675a171c4eb6f84949ef5645b6c0dff5fdbf9c9df823f4f4ece9e5ad1fa3eda43559f419f36b77afb333 languageName: node linkType: hard @@ -8863,7 +8863,7 @@ __metadata: resolution: "api@workspace:frontends/api" dependencies: "@faker-js/faker": "npm:^10.0.0" - "@mitodl/mitxonline-api-axios": "npm:^2026.3.24" + "@mitodl/mitxonline-api-axios": "npm:^2026.3.25" "@tanstack/react-query": "npm:^5.66.0" "@testing-library/react": "npm:^16.3.0" axios: "npm:^1.12.2" @@ -16058,7 +16058,7 @@ __metadata: "@floating-ui/react": "npm:^0.27.16" "@happy-dom/jest-environment": "npm:^20.1.0" "@mitodl/course-search-utils": "npm:^3.5.2" - "@mitodl/mitxonline-api-axios": "npm:^2026.3.24" + "@mitodl/mitxonline-api-axios": "npm:^2026.3.25" "@mitodl/smoot-design": "npm:^6.24.0" "@mui/material": "npm:^6.4.5" "@mui/material-nextjs": "npm:^6.4.3" From b49ad9375d2858079555038253f5cea88b8fea04 Mon Sep 17 00:00:00 2001 From: Doof Date: Wed, 25 Mar 2026 18:02:44 +0000 Subject: [PATCH 7/7] Release 0.59.5 --- RELEASE.rst | 10 ++++++++++ main/settings.py | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/RELEASE.rst b/RELEASE.rst index 1692a24c0a..3b20cc8b1e 100644 --- a/RELEASE.rst +++ b/RELEASE.rst @@ -1,6 +1,16 @@ Release Notes ============= +Version 0.59.5 +-------------- + +- Fix verified enrollment for program-as-course hierarchy (#3100) +- fix(deps): update dependency cairosvg to v2.9.0 [security] (#3052) +- hide mitxonline logo and offered by (#3097) +- Upgrade Qdrant (#3087) +- feat: add ProgramBundleUpsell to ProgramAsCoursePage (#3092) +- update the client again! (#3094) + Version 0.59.4 (Released March 25, 2026) -------------- diff --git a/main/settings.py b/main/settings.py index 52258b9d3c..b82f770b26 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.59.4" +VERSION = "0.59.5" log = logging.getLogger()