From 75d6146c97f19623a4a82ce4cd5eb16358f5ed20 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 12 May 2026 10:18:24 -0400 Subject: [PATCH 1/9] Update dependency urllib3 to v2.7.0 [SECURITY] (#3329) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- uv.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/uv.lock b/uv.lock index 001d7183cf..040b4f9fab 100644 --- a/uv.lock +++ b/uv.lock @@ -5132,11 +5132,11 @@ wheels = [ [[package]] name = "urllib3" -version = "2.6.3" +version = "2.7.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, ] [package.optional-dependencies] From 7800189e17487f88b28cf653b9f49d3de4961228 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 12 May 2026 11:44:07 -0400 Subject: [PATCH 2/9] Update dependency litellm to v1.83.10 [SECURITY] (#3331) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- pyproject.toml | 2 +- uv.lock | 52 +++++++++++++++++++++++++------------------------- 2 files changed, 27 insertions(+), 27 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 2bafe84139..792a5996c2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -57,7 +57,7 @@ dependencies = [ "langchain>=0.3.11,<0.4", "langchain-experimental>=0.3.4,<0.4", "langchain-openai>=0.3.2,<0.4", - "litellm==1.83.7", + "litellm==1.83.10", "llama-index>=0.14.0,<0.15", "llama-index-llms-openai>=0.6.0,<0.7", "lxml>=6.0.0,<7", diff --git a/uv.lock b/uv.lock index 040b4f9fab..3492026abd 100644 --- a/uv.lock +++ b/uv.lock @@ -16,7 +16,7 @@ wheels = [ [[package]] name = "aiohttp" -version = "3.13.5" +version = "3.13.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohappyeyeballs" }, @@ -27,25 +27,25 @@ dependencies = [ { name = "propcache" }, { name = "yarl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/77/9a/152096d4808df8e4268befa55fba462f440f14beab85e8ad9bf990516918/aiohttp-3.13.5.tar.gz", hash = "sha256:9d98cc980ecc96be6eb4c1994ce35d28d8b1f5e5208a23b421187d1209dbb7d1", size = 7858271, upload-time = "2026-03-31T22:01:03.343Z" } +sdist = { url = "https://files.pythonhosted.org/packages/50/42/32cf8e7704ceb4481406eb87161349abb46a57fee3f008ba9cb610968646/aiohttp-3.13.3.tar.gz", hash = "sha256:a949eee43d3782f2daae4f4a2819b2cb9b0c5d3b7f7a927067cc84dafdbb9f88", size = 7844556, upload-time = "2026-01-03T17:33:05.204Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/be/6f/353954c29e7dcce7cf00280a02c75f30e133c00793c7a2ed3776d7b2f426/aiohttp-3.13.5-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:023ecba036ddd840b0b19bf195bfae970083fd7024ce1ac22e9bba90464620e9", size = 748876, upload-time = "2026-03-31T21:57:36.319Z" }, - { url = "https://files.pythonhosted.org/packages/f5/1b/428a7c64687b3b2e9cd293186695affc0e1e54a445d0361743b231f11066/aiohttp-3.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:15c933ad7920b7d9a20de151efcd05a6e38302cbf0e10c9b2acb9a42210a2416", size = 499557, upload-time = "2026-03-31T21:57:38.236Z" }, - { url = "https://files.pythonhosted.org/packages/29/47/7be41556bfbb6917069d6a6634bb7dd5e163ba445b783a90d40f5ac7e3a7/aiohttp-3.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ab2899f9fa2f9f741896ebb6fa07c4c883bfa5c7f2ddd8cf2aafa86fa981b2d2", size = 500258, upload-time = "2026-03-31T21:57:39.923Z" }, - { url = "https://files.pythonhosted.org/packages/67/84/c9ecc5828cb0b3695856c07c0a6817a99d51e2473400f705275a2b3d9239/aiohttp-3.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a60eaa2d440cd4707696b52e40ed3e2b0f73f65be07fd0ef23b6b539c9c0b0b4", size = 1749199, upload-time = "2026-03-31T21:57:41.938Z" }, - { url = "https://files.pythonhosted.org/packages/f0/d3/3c6d610e66b495657622edb6ae7c7fd31b2e9086b4ec50b47897ad6042a9/aiohttp-3.13.5-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:55b3bdd3292283295774ab585160c4004f4f2f203946997f49aac032c84649e9", size = 1721013, upload-time = "2026-03-31T21:57:43.904Z" }, - { url = "https://files.pythonhosted.org/packages/49/a0/24409c12217456df0bae7babe3b014e460b0b38a8e60753d6cb339f6556d/aiohttp-3.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2b2355dc094e5f7d45a7bb262fe7207aa0460b37a0d87027dcf21b5d890e7d5", size = 1781501, upload-time = "2026-03-31T21:57:46.285Z" }, - { url = "https://files.pythonhosted.org/packages/98/9d/b65ec649adc5bccc008b0957a9a9c691070aeac4e41cea18559fef49958b/aiohttp-3.13.5-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b38765950832f7d728297689ad78f5f2cf79ff82487131c4d26fe6ceecdc5f8e", size = 1878981, upload-time = "2026-03-31T21:57:48.734Z" }, - { url = "https://files.pythonhosted.org/packages/57/d8/8d44036d7eb7b6a8ec4c5494ea0c8c8b94fbc0ed3991c1a7adf230df03bf/aiohttp-3.13.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b18f31b80d5a33661e08c89e202edabf1986e9b49c42b4504371daeaa11b47c1", size = 1767934, upload-time = "2026-03-31T21:57:51.171Z" }, - { url = "https://files.pythonhosted.org/packages/31/04/d3f8211f273356f158e3464e9e45484d3fb8c4ce5eb2f6fe9405c3273983/aiohttp-3.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:33add2463dde55c4f2d9635c6ab33ce154e5ecf322bd26d09af95c5f81cfa286", size = 1566671, upload-time = "2026-03-31T21:57:53.326Z" }, - { url = "https://files.pythonhosted.org/packages/41/db/073e4ebe00b78e2dfcacff734291651729a62953b48933d765dc513bf798/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:327cc432fdf1356fb4fbc6fe833ad4e9f6aacb71a8acaa5f1855e4b25910e4a9", size = 1705219, upload-time = "2026-03-31T21:57:55.385Z" }, - { url = "https://files.pythonhosted.org/packages/48/45/7dfba71a2f9fd97b15c95c06819de7eb38113d2cdb6319669195a7d64270/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:7c35b0bf0b48a70b4cb4fc5d7bed9b932532728e124874355de1a0af8ec4bc88", size = 1743049, upload-time = "2026-03-31T21:57:57.341Z" }, - { url = "https://files.pythonhosted.org/packages/18/71/901db0061e0f717d226386a7f471bb59b19566f2cae5f0d93874b017271f/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:df23d57718f24badef8656c49743e11a89fd6f5358fa8a7b96e728fda2abf7d3", size = 1749557, upload-time = "2026-03-31T21:57:59.626Z" }, - { url = "https://files.pythonhosted.org/packages/08/d5/41eebd16066e59cd43728fe74bce953d7402f2b4ddfdfef2c0e9f17ca274/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:02e048037a6501a5ec1f6fc9736135aec6eb8a004ce48838cb951c515f32c80b", size = 1558931, upload-time = "2026-03-31T21:58:01.972Z" }, - { url = "https://files.pythonhosted.org/packages/30/e6/4a799798bf05740e66c3a1161079bda7a3dd8e22ca392481d7a7f9af82a6/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:31cebae8b26f8a615d2b546fee45d5ffb76852ae6450e2a03f42c9102260d6fe", size = 1774125, upload-time = "2026-03-31T21:58:04.007Z" }, - { url = "https://files.pythonhosted.org/packages/84/63/7749337c90f92bc2cb18f9560d67aa6258c7060d1397d21529b8004fcf6f/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:888e78eb5ca55a615d285c3c09a7a91b42e9dd6fc699b166ebd5dee87c9ccf14", size = 1732427, upload-time = "2026-03-31T21:58:06.337Z" }, - { url = "https://files.pythonhosted.org/packages/98/de/cf2f44ff98d307e72fb97d5f5bbae3bfcb442f0ea9790c0bf5c5c2331404/aiohttp-3.13.5-cp312-cp312-win32.whl", hash = "sha256:8bd3ec6376e68a41f9f95f5ed170e2fcf22d4eb27a1f8cb361d0508f6e0557f3", size = 433534, upload-time = "2026-03-31T21:58:08.712Z" }, - { url = "https://files.pythonhosted.org/packages/aa/ca/eadf6f9c8fa5e31d40993e3db153fb5ed0b11008ad5d9de98a95045bed84/aiohttp-3.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:110e448e02c729bcebb18c60b9214a87ba33bac4a9fa5e9a5f139938b56c6cb1", size = 460446, upload-time = "2026-03-31T21:58:10.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/be/4fc11f202955a69e0db803a12a062b8379c970c7c84f4882b6da17337cc1/aiohttp-3.13.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b903a4dfee7d347e2d87697d0713be59e0b87925be030c9178c5faa58ea58d5c", size = 739732, upload-time = "2026-01-03T17:30:14.23Z" }, + { url = "https://files.pythonhosted.org/packages/97/2c/621d5b851f94fa0bb7430d6089b3aa970a9d9b75196bc93bb624b0db237a/aiohttp-3.13.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a45530014d7a1e09f4a55f4f43097ba0fd155089372e105e4bff4ca76cb1b168", size = 494293, upload-time = "2026-01-03T17:30:15.96Z" }, + { url = "https://files.pythonhosted.org/packages/5d/43/4be01406b78e1be8320bb8316dc9c42dbab553d281c40364e0f862d5661c/aiohttp-3.13.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:27234ef6d85c914f9efeb77ff616dbf4ad2380be0cda40b4db086ffc7ddd1b7d", size = 493533, upload-time = "2026-01-03T17:30:17.431Z" }, + { url = "https://files.pythonhosted.org/packages/8d/a8/5a35dc56a06a2c90d4742cbf35294396907027f80eea696637945a106f25/aiohttp-3.13.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d32764c6c9aafb7fb55366a224756387cd50bfa720f32b88e0e6fa45b27dcf29", size = 1737839, upload-time = "2026-01-03T17:30:19.422Z" }, + { url = "https://files.pythonhosted.org/packages/bf/62/4b9eeb331da56530bf2e198a297e5303e1c1ebdceeb00fe9b568a65c5a0c/aiohttp-3.13.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b1a6102b4d3ebc07dad44fbf07b45bb600300f15b552ddf1851b5390202ea2e3", size = 1703932, upload-time = "2026-01-03T17:30:21.756Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f6/af16887b5d419e6a367095994c0b1332d154f647e7dc2bd50e61876e8e3d/aiohttp-3.13.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c014c7ea7fb775dd015b2d3137378b7be0249a448a1612268b5a90c2d81de04d", size = 1771906, upload-time = "2026-01-03T17:30:23.932Z" }, + { url = "https://files.pythonhosted.org/packages/ce/83/397c634b1bcc24292fa1e0c7822800f9f6569e32934bdeef09dae7992dfb/aiohttp-3.13.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2b8d8ddba8f95ba17582226f80e2de99c7a7948e66490ef8d947e272a93e9463", size = 1871020, upload-time = "2026-01-03T17:30:26Z" }, + { url = "https://files.pythonhosted.org/packages/86/f6/a62cbbf13f0ac80a70f71b1672feba90fdb21fd7abd8dbf25c0105fb6fa3/aiohttp-3.13.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9ae8dd55c8e6c4257eae3a20fd2c8f41edaea5992ed67156642493b8daf3cecc", size = 1755181, upload-time = "2026-01-03T17:30:27.554Z" }, + { url = "https://files.pythonhosted.org/packages/0a/87/20a35ad487efdd3fba93d5843efdfaa62d2f1479eaafa7453398a44faf13/aiohttp-3.13.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:01ad2529d4b5035578f5081606a465f3b814c542882804e2e8cda61adf5c71bf", size = 1561794, upload-time = "2026-01-03T17:30:29.254Z" }, + { url = "https://files.pythonhosted.org/packages/de/95/8fd69a66682012f6716e1bc09ef8a1a2a91922c5725cb904689f112309c4/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bb4f7475e359992b580559e008c598091c45b5088f28614e855e42d39c2f1033", size = 1697900, upload-time = "2026-01-03T17:30:31.033Z" }, + { url = "https://files.pythonhosted.org/packages/e5/66/7b94b3b5ba70e955ff597672dad1691333080e37f50280178967aff68657/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:c19b90316ad3b24c69cd78d5c9b4f3aa4497643685901185b65166293d36a00f", size = 1728239, upload-time = "2026-01-03T17:30:32.703Z" }, + { url = "https://files.pythonhosted.org/packages/47/71/6f72f77f9f7d74719692ab65a2a0252584bf8d5f301e2ecb4c0da734530a/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:96d604498a7c782cb15a51c406acaea70d8c027ee6b90c569baa6e7b93073679", size = 1740527, upload-time = "2026-01-03T17:30:34.695Z" }, + { url = "https://files.pythonhosted.org/packages/fa/b4/75ec16cbbd5c01bdaf4a05b19e103e78d7ce1ef7c80867eb0ace42ff4488/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:084911a532763e9d3dd95adf78a78f4096cd5f58cdc18e6fdbc1b58417a45423", size = 1554489, upload-time = "2026-01-03T17:30:36.864Z" }, + { url = "https://files.pythonhosted.org/packages/52/8f/bc518c0eea29f8406dcf7ed1f96c9b48e3bc3995a96159b3fc11f9e08321/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:7a4a94eb787e606d0a09404b9c38c113d3b099d508021faa615d70a0131907ce", size = 1767852, upload-time = "2026-01-03T17:30:39.433Z" }, + { url = "https://files.pythonhosted.org/packages/9d/f2/a07a75173124f31f11ea6f863dc44e6f09afe2bca45dd4e64979490deab1/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:87797e645d9d8e222e04160ee32aa06bc5c163e8499f24db719e7852ec23093a", size = 1722379, upload-time = "2026-01-03T17:30:41.081Z" }, + { url = "https://files.pythonhosted.org/packages/3c/4a/1a3fee7c21350cac78e5c5cef711bac1b94feca07399f3d406972e2d8fcd/aiohttp-3.13.3-cp312-cp312-win32.whl", hash = "sha256:b04be762396457bef43f3597c991e192ee7da460a4953d7e647ee4b1c28e7046", size = 428253, upload-time = "2026-01-03T17:30:42.644Z" }, + { url = "https://files.pythonhosted.org/packages/d9/b7/76175c7cb4eb73d91ad63c34e29fc4f77c9386bba4a65b53ba8e05ee3c39/aiohttp-3.13.3-cp312-cp312-win_amd64.whl", hash = "sha256:e3531d63d3bdfa7e3ac5e9b27b2dd7ec9df3206a98e0b3445fa906f233264c57", size = 455407, upload-time = "2026-01-03T17:30:44.195Z" }, ] [[package]] @@ -2115,7 +2115,7 @@ wheels = [ [[package]] name = "litellm" -version = "1.83.7" +version = "1.83.10" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohttp" }, @@ -2131,9 +2131,9 @@ dependencies = [ { name = "tiktoken" }, { name = "tokenizers" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/77/2b/b58bf6bbcbc3d0e55d0a84fdf9128e5b1436517f46fce89b1cd8948ebb81/litellm-1.83.7.tar.gz", hash = "sha256:e2f2cb99df2e2b2eab63f1354faa45c88dd7c8d40c18eb648afb1b349c689633", size = 17791694, upload-time = "2026-04-13T17:35:01.606Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e2/16/fc51935e406887079f0d7c20427b9a15b9fc1d558e68b4e7072331b70db4/litellm-1.83.10.tar.gz", hash = "sha256:d54eaa98f93a1eb02decf593dbb525fa1ddd4cf03686c1d5c7bb69c2a9ba2a41", size = 14726546, upload-time = "2026-04-19T02:36:28.586Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/75/80/caeb4cdcad96451ba83ad3ba2a9da08b1e1a915fa845c489f56ea044488b/litellm-1.83.7-py3-none-any.whl", hash = "sha256:5784a1d9a9a4a8acd6ca1e347003a5e2e1b3c749b4d41e7da4904577adade111", size = 16069807, upload-time = "2026-04-13T17:34:58.36Z" }, + { url = "https://files.pythonhosted.org/packages/6f/58/8dd69d5b1ab11f206a9c9f21b6fd191bcdd4fb3ed90b8efbf3a1291fd47c/litellm-1.83.10-py3-none-any.whl", hash = "sha256:55203a7b5551efec8f2fccde29ee045ba057e768591e0b6b9fe1d12f00685ff8", size = 16334780, upload-time = "2026-04-19T02:36:25.274Z" }, ] [[package]] @@ -2677,7 +2677,7 @@ requires-dist = [ { name = "langchain-experimental", specifier = ">=0.3.4,<0.4" }, { name = "langchain-litellm", specifier = ">=0.5.1" }, { name = "langchain-openai", specifier = ">=0.3.2,<0.4" }, - { name = "litellm", specifier = "==1.83.7" }, + { name = "litellm", specifier = "==1.83.10" }, { name = "llama-index", specifier = ">=0.14.0,<0.15" }, { name = "llama-index-llms-openai", specifier = ">=0.6.0,<0.7" }, { name = "lxml", specifier = ">=6.0.0,<7" }, @@ -3092,7 +3092,7 @@ wheels = [ [[package]] name = "openai" -version = "2.30.0" +version = "2.24.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -3104,9 +3104,9 @@ dependencies = [ { name = "tqdm" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/88/15/52580c8fbc16d0675d516e8749806eda679b16de1e4434ea06fb6feaa610/openai-2.30.0.tar.gz", hash = "sha256:92f7661c990bda4b22a941806c83eabe4896c3094465030dd882a71abe80c885", size = 676084, upload-time = "2026-03-25T22:08:59.96Z" } +sdist = { url = "https://files.pythonhosted.org/packages/55/13/17e87641b89b74552ed408a92b231283786523edddc95f3545809fab673c/openai-2.24.0.tar.gz", hash = "sha256:1e5769f540dbd01cb33bc4716a23e67b9d695161a734aff9c5f925e2bf99a673", size = 658717, upload-time = "2026-02-24T20:02:07.958Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/9e/5bfa2270f902d5b92ab7d41ce0475b8630572e71e349b2a4996d14bdda93/openai-2.30.0-py3-none-any.whl", hash = "sha256:9a5ae616888eb2748ec5e0c5b955a51592e0b201a11f4262db920f2a78c5231d", size = 1146656, upload-time = "2026-03-25T22:08:58.2Z" }, + { url = "https://files.pythonhosted.org/packages/c9/30/844dc675ee6902579b8eef01ed23917cc9319a1c9c0c14ec6e39340c96d0/openai-2.24.0-py3-none-any.whl", hash = "sha256:fed30480d7d6c884303287bde864980a4b137b60553ffbcf9ab4a233b7a73d94", size = 1120122, upload-time = "2026-02-24T20:02:05.669Z" }, ] [[package]] From 2afc28bd5f79d61e5937fe42bbe2e69711ace991 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 12 May 2026 11:44:26 -0400 Subject: [PATCH 3/9] Update dependency granian to v2.7.4 [SECURITY] (#3309) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- uv.lock | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/uv.lock b/uv.lock index 3492026abd..731ac992d2 100644 --- a/uv.lock +++ b/uv.lock @@ -1437,23 +1437,23 @@ wheels = [ [[package]] name = "granian" -version = "2.7.2" +version = "2.7.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/57/19/d4ea523715ba8dd2ed295932cc3dda6bb197060f78aada6e886ff08587b2/granian-2.7.2.tar.gz", hash = "sha256:cdae2f3a26fa998d41fefad58f1d1c84a0b035a6cc9377addd81b51ba82f927f", size = 128969, upload-time = "2026-02-24T23:04:23.314Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/ed/37f5d7d887ec9159dd8f5b1c9c38cee711d51016d203959f2d51c536a33b/granian-2.7.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a836f3f8ebfe61cb25d9afb655f2e5d3851154fd2ad97d47bb4fb202817212fc", size = 6451593, upload-time = "2026-02-24T23:02:36.203Z" }, - { url = "https://files.pythonhosted.org/packages/1e/06/84ee67a68504836a52c48ec3b4b2b406cbd927c9b43aae89d82db8d097a0/granian-2.7.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:09b1c543ba30886dea515a156baf6d857bbb8b57dbfd8b012c578b93c80ef0c3", size = 6101239, upload-time = "2026-02-24T23:02:37.636Z" }, - { url = "https://files.pythonhosted.org/packages/ed/50/ece7dc8efe144542cd626b88b1475b649e2eaa3eb5f7541ca57390151b05/granian-2.7.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6d334d4fbefb97001e78aa8067deafb107b867c102ba2120b4b2ec989fa58a89", size = 7079443, upload-time = "2026-02-24T23:02:39.651Z" }, - { url = "https://files.pythonhosted.org/packages/7e/e8/0f37b531d3cc96b8538cca2dc86eda92102e0ee345b30aa689354194a4cb/granian-2.7.2-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8c86081d8c87989db69650e9d0e50ed925b8cd5dad21e0a86aa72d7a45f45925", size = 6428683, upload-time = "2026-02-24T23:02:41.827Z" }, - { url = "https://files.pythonhosted.org/packages/47/09/228626706554b389407270e2a6b19b7dee06d6890e8c01a39c6a785827fd/granian-2.7.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d9eda33dca2c8bc6471bb6e9e25863077bca3877a1bba4069cd5e0ee2de41765", size = 6959520, upload-time = "2026-02-24T23:02:43.488Z" }, - { url = "https://files.pythonhosted.org/packages/61/c0/a639ceabd59b8acae2d71b5c918fcb2d42f8ef98994eedcf9a8b6813731d/granian-2.7.2-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:9cf69aaff6f632074ffbe7c1ee214e50f64be36101b7cb8253eeec1d460f2dba", size = 6991548, upload-time = "2026-02-24T23:02:44.954Z" }, - { url = "https://files.pythonhosted.org/packages/b1/99/a35ed838a3095dcad02ae3944d19ebafe1d5a98cdc72bb61835fb5faf933/granian-2.7.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:f761a748cc7f3843b430422d2539da679daf5d3ef0259a101b90d5e55a0aafa7", size = 7121475, upload-time = "2026-02-24T23:02:46.991Z" }, - { url = "https://files.pythonhosted.org/packages/ce/24/3952c464432b904ec1cf537d2bd80d2dfde85524fa428ab9db2b5afe653c/granian-2.7.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:41c7b8390b78647fe34662ed7296e1465dad4a5112af9b0ecf8e367083d6c76a", size = 7243647, upload-time = "2026-02-24T23:02:49.165Z" }, - { url = "https://files.pythonhosted.org/packages/c9/fa/ab39e39c6b78eab6b42cf5bb36f56badde2aaafc3807f03f781d00e7861a/granian-2.7.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a052ed466da5922cb443435a95a0c751566943278a6f22cef3d2e19d4e7ecdea", size = 7048915, upload-time = "2026-02-24T23:02:50.773Z" }, - { url = "https://files.pythonhosted.org/packages/39/64/4502918f7d92a7e668d9e2fba83e2decbbf44c8ea896bacd8551d64f1d29/granian-2.7.2-cp312-cp312-win_amd64.whl", hash = "sha256:1e438096c36ed6aa4f6c0c8dde22bebe08ac008d08257517b15182c262a08cfa", size = 4150398, upload-time = "2026-02-24T23:02:52.199Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/db/0c/27aa25280b6c1f323312e83088304da8a7f3e5c1e568d3a560365ec6fa67/granian-2.7.4.tar.gz", hash = "sha256:1dc0530d7ae6b0ae43aafafe771ac0b8c38af68bbd71ab355828817faf13aac1", size = 128212, upload-time = "2026-04-23T11:55:55.275Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/d9/148024fd3a8bd974bb5c68a0cb48d15df7763fd1364bf090ccc2d423028a/granian-2.7.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:2c2f40aaecf2ba3d8232e55181c8f6db7bc68d9112a419ab8d5f9e2f33f631f5", size = 6374067, upload-time = "2026-04-23T11:54:07.293Z" }, + { url = "https://files.pythonhosted.org/packages/fc/bb/c53b61a7cb67d33677d96913438eca3d79de1b1b7173a361fcdf2753ade7/granian-2.7.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a8111d5e74b27721e0fdda3edba7c154d44c41b469466857ca3c51b088e3846b", size = 6046338, upload-time = "2026-04-23T11:54:08.684Z" }, + { url = "https://files.pythonhosted.org/packages/29/8d/5c9dc91b9c9a05bf6ed0b795d30f4bb8f290d61502779a89ed2fd75f9fb6/granian-2.7.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:74adbb6c1920dbf4271b824135639318b2a20ff5e33bc35639a8e2928a777234", size = 7000585, upload-time = "2026-04-23T11:54:10.451Z" }, + { url = "https://files.pythonhosted.org/packages/d9/7c/c770593b24a472ab5265a44546f56079757efbf89f8e8b2229a8443e453b/granian-2.7.4-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0b778d356b61e0389c823016ad2be50a634b80d3d28a33922f7ac39553e828ad", size = 6255544, upload-time = "2026-04-23T11:54:12.484Z" }, + { url = "https://files.pythonhosted.org/packages/15/46/796147587edb494a330294cb001cf68520ad8296a7da91d80ec672ac8615/granian-2.7.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3607b091c4ef225ee99150f3b02cb827de8d677b52fc75f0b28893244f7bab27", size = 6875124, upload-time = "2026-04-23T11:54:13.967Z" }, + { url = "https://files.pythonhosted.org/packages/c5/25/b867f624886e11053e7a6235244de26fd864a136e65d12295e728b3e5005/granian-2.7.4-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:3d3cf4fe3cafd9b874d8b749c66c790cbf2b4225f2a7d9fb284c51b77a8e938d", size = 6982394, upload-time = "2026-04-23T11:54:15.733Z" }, + { url = "https://files.pythonhosted.org/packages/4f/e1/5746bfe202bd2f6a1506346463ce52dd015c2b5d03d07a53ecf0fddefa3f/granian-2.7.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:846c9cbfea8684ab13d21d66855ad06dc077fb95b5590e7f5040e79994d6429d", size = 6991457, upload-time = "2026-04-23T11:54:17.325Z" }, + { url = "https://files.pythonhosted.org/packages/e0/45/fc6992839d367b6ae8fa8d88b5e70ec293162c3a2e0e6b90fc426f228df2/granian-2.7.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:d34d97cfe4a7805ecb5b1b1684f3f197bb4baf019d2a9f18e34fd1d697a03a7f", size = 7148499, upload-time = "2026-04-23T11:54:19.234Z" }, + { url = "https://files.pythonhosted.org/packages/fe/12/16ffd64a1213858d4cf824767b398758be807dd1a6df5a303dc76994b6d6/granian-2.7.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:f11336e4bcd8ef5c5143b075b5260e37e8431eb36d68564cc39416ca526c797f", size = 7006829, upload-time = "2026-04-23T11:54:20.804Z" }, + { url = "https://files.pythonhosted.org/packages/95/9a/f2fcda200f8739ddf25be72591b7a28897be0ffd952a76ec655e5f877144/granian-2.7.4-cp312-cp312-win_amd64.whl", hash = "sha256:9e0a4370773ec4a0e92a55a33fc700b60003e335480e5c7fe941f4bc3dda2e18", size = 4026771, upload-time = "2026-04-23T11:54:22.36Z" }, ] [package.optional-dependencies] From ef1dcc89b3bb66be56c1940494d22e610ab3c2c9 Mon Sep 17 00:00:00 2001 From: Shankar Ambady Date: Tue, 12 May 2026 14:50:05 -0400 Subject: [PATCH 4/9] Min score for vector learning resources endpoint (#3285) * adding cutoff to serializer * adding param to search method * refactor of sync search view * ensuring all results are returned if there is a query with score_cutoff * ensuring all results are returned if there is a query with score_cutoff * spec update * adding frontend changes * fix test * fix tests * introduce manual aggregation method * re-introduce spacing at bottom * fix facet behavior * fix lint * fix lint * adding docstring * refactor to not use pandas * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Fix facet sorting Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * update spec * style lint * avoid call to aggregations * move import to top * adding test for aggregation buckets * only include the threshold if greater than 0 * refactor min score default values. add min score to settings * enforce limit and remove count call to fetch total * add max limit setting * add test * udpate spec * fix test after refactor * set default to min score * fix test * spec update * fix test * fix sorting * moving min score to constant --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- frontends/api/src/generated/v0/api.ts | 18 ++ .../app-pages/SearchPage/SearchPage.test.tsx | 72 ++++++ .../SearchDisplay/SearchDisplay.tsx | 225 ++++++++++++++--- main/settings.py | 4 + openapi/specs/v0.yaml | 8 + vector_search/constants.py | 3 + vector_search/serializers.py | 7 + vector_search/utils.py | 12 +- vector_search/utils_test.py | 2 +- vector_search/views.py | 236 +++++++++++++++--- vector_search/views_test.py | 147 ++++++++++- 11 files changed, 656 insertions(+), 78 deletions(-) diff --git a/frontends/api/src/generated/v0/api.ts b/frontends/api/src/generated/v0/api.ts index 434b7e64c5..b4db550aaa 100644 --- a/frontends/api/src/generated/v0/api.ts +++ b/frontends/api/src/generated/v0/api.ts @@ -12031,6 +12031,7 @@ export const VectorLearningResourcesSearchApiAxiosParamCreator = function ( * @param {string} [readable_id] The readable id of the resource * @param {Array} [resource_type] The type of learning resource * `course` - course * `program` - program * `learning_path` - learning path * `podcast` - podcast * `podcast_episode` - podcast episode * `video` - video * `video_playlist` - video playlist * `document` - document * @param {Array} [resource_type_group] The category of learning resource * `course` - Course * `program` - Program * `learning_material` - Learning Material + * @param {number} [score_cutoff] The minimum score a result must have to be returned * @param {VectorLearningResourcesSearchRetrieveSortbyEnum} [sortby] if the parameter starts with \'-\' the sort is in descending order * `next_start_date` - next_start_date * `views` - views * `created_on` - created_on * `-next_start_date` - -next_start_date * `-views` - -views * `-created_on` - -created_on * @param {boolean | null} [title__isnull] Filter to learning resources where title is null/not null * @param {Array} [topic] The topic name. To see a list of options go to api/v1/topics/ @@ -12059,6 +12060,7 @@ export const VectorLearningResourcesSearchApiAxiosParamCreator = function ( readable_id?: string, resource_type?: Array, resource_type_group?: Array, + score_cutoff?: number, sortby?: VectorLearningResourcesSearchRetrieveSortbyEnum, title__isnull?: boolean | null, topic?: Array, @@ -12161,6 +12163,10 @@ export const VectorLearningResourcesSearchApiAxiosParamCreator = function ( localVarQueryParameter["resource_type_group"] = resource_type_group } + if (score_cutoff !== undefined) { + localVarQueryParameter["score_cutoff"] = score_cutoff + } + if (sortby !== undefined) { localVarQueryParameter["sortby"] = sortby } @@ -12227,6 +12233,7 @@ export const VectorLearningResourcesSearchApiFp = function ( * @param {string} [readable_id] The readable id of the resource * @param {Array} [resource_type] The type of learning resource * `course` - course * `program` - program * `learning_path` - learning path * `podcast` - podcast * `podcast_episode` - podcast episode * `video` - video * `video_playlist` - video playlist * `document` - document * @param {Array} [resource_type_group] The category of learning resource * `course` - Course * `program` - Program * `learning_material` - Learning Material + * @param {number} [score_cutoff] The minimum score a result must have to be returned * @param {VectorLearningResourcesSearchRetrieveSortbyEnum} [sortby] if the parameter starts with \'-\' the sort is in descending order * `next_start_date` - next_start_date * `views` - views * `created_on` - created_on * `-next_start_date` - -next_start_date * `-views` - -views * `-created_on` - -created_on * @param {boolean | null} [title__isnull] Filter to learning resources where title is null/not null * @param {Array} [topic] The topic name. To see a list of options go to api/v1/topics/ @@ -12255,6 +12262,7 @@ export const VectorLearningResourcesSearchApiFp = function ( readable_id?: string, resource_type?: Array, resource_type_group?: Array, + score_cutoff?: number, sortby?: VectorLearningResourcesSearchRetrieveSortbyEnum, title__isnull?: boolean | null, topic?: Array, @@ -12288,6 +12296,7 @@ export const VectorLearningResourcesSearchApiFp = function ( readable_id, resource_type, resource_type_group, + score_cutoff, sortby, title__isnull, topic, @@ -12354,6 +12363,7 @@ export const VectorLearningResourcesSearchApiFactory = function ( requestParameters.readable_id, requestParameters.resource_type, requestParameters.resource_type_group, + requestParameters.score_cutoff, requestParameters.sortby, requestParameters.title__isnull, requestParameters.topic, @@ -12511,6 +12521,13 @@ export interface VectorLearningResourcesSearchApiVectorLearningResourcesSearchRe */ readonly resource_type_group?: Array + /** + * The minimum score a result must have to be returned + * @type {number} + * @memberof VectorLearningResourcesSearchApiVectorLearningResourcesSearchRetrieve + */ + readonly score_cutoff?: number + /** * if the parameter starts with \'-\' the sort is in descending order * `next_start_date` - next_start_date * `views` - views * `created_on` - created_on * `-next_start_date` - -next_start_date * `-views` - -views * `-created_on` - -created_on * @type {'next_start_date' | 'views' | 'created_on' | '-next_start_date' | '-views' | '-created_on'} @@ -12581,6 +12598,7 @@ export class VectorLearningResourcesSearchApi extends BaseAPI { requestParameters.readable_id, requestParameters.resource_type, requestParameters.resource_type_group, + requestParameters.score_cutoff, requestParameters.sortby, requestParameters.title__isnull, requestParameters.topic, diff --git a/frontends/main/src/app-pages/SearchPage/SearchPage.test.tsx b/frontends/main/src/app-pages/SearchPage/SearchPage.test.tsx index dd7a8dfd17..84b7e21a29 100644 --- a/frontends/main/src/app-pages/SearchPage/SearchPage.test.tsx +++ b/frontends/main/src/app-pages/SearchPage/SearchPage.test.tsx @@ -12,6 +12,7 @@ import type { LearningResourcesSearchResponse, PaginatedLearningResourceOfferorDetailList, } from "api" +import { ResourceTypeEnum } from "api" import invariant from "tiny-invariant" import { Permission } from "api/hooks/user" import { @@ -1084,6 +1085,77 @@ describe("UniversalAIBanner", () => { }) }) + test("Vector Hybrid Search with a query hides pagination and filters results locally", async () => { + const physicsTopic = factories.learningResources.topic({ name: "Physics" }) + const chemistryTopic = factories.learningResources.topic({ + name: "Chemistry", + }) + const resources = [ + factories.learningResources.resource({ + title: "Physics Result", + resource_type: ResourceTypeEnum.Course, + topics: [physicsTopic], + }), + factories.learningResources.resource({ + title: "Chemistry Result", + resource_type: ResourceTypeEnum.Course, + topics: [chemistryTopic], + }), + ] + + setMockApiResponses({ + search: { + count: 2, + metadata: { + aggregations: { + topic: [ + { key: "Physics", doc_count: 1 }, + { key: "Chemistry", doc_count: 1 }, + ], + resource_type_group: [{ key: "course", doc_count: 2 }], + }, + suggestions: [], + }, + results: resources, + }, + }) + setMockResponse.get(urls.userMe.get(), { + is_learning_path_editor: true, + is_authenticated: true, + }) + + const { location } = renderWithProviders(, { + url: "?vector_search=true&q=test", + }) + + await screen.findByText("Physics Result") + await screen.findByText("Chemistry Result") + expect( + screen.queryByRole("navigation", { name: /pagination/i }), + ).not.toBeInTheDocument() + + const initialVectorRequestCount = makeRequest.mock.calls.filter( + ([method, url]) => + method === "get" && url.includes(urls.search.vectorResources()), + ).length + + await user.click(screen.getByRole("button", { name: /Topic/i })) + await user.click(screen.getByRole("checkbox", { name: "Physics 1" })) + + const searchParams = new URLSearchParams(location.current.search) + expect(searchParams.get("vector_search")).toBe("true") + expect(searchParams.get("q")).toBe("test") + expect(searchParams.get("topic")).toBe("Physics") + expect(await screen.findByText("Physics Result")).toBeVisible() + expect(screen.queryByText("Chemistry Result")).not.toBeInTheDocument() + + const finalVectorRequestCount = makeRequest.mock.calls.filter( + ([method, url]) => + method === "get" && url.includes(urls.search.vectorResources()), + ).length + expect(finalVectorRequestCount).toBe(initialVectorRequestCount) + }) + test("clicking Learn More fires cta_clicked with label and readableId", async () => { allowConsoleErrors() mockedUseFeatureFlagEnabled.mockReturnValue(true) diff --git a/frontends/main/src/page-components/SearchDisplay/SearchDisplay.tsx b/frontends/main/src/page-components/SearchDisplay/SearchDisplay.tsx index c6c8cb2e27..cc0c9df830 100644 --- a/frontends/main/src/page-components/SearchDisplay/SearchDisplay.tsx +++ b/frontends/main/src/page-components/SearchDisplay/SearchDisplay.tsx @@ -566,6 +566,135 @@ const toVectorSearchParams = ( hybrid_search: true, }) +const VECTOR_CLIENT_FILTER_FACETS = [ + "resource_type", + "certification_type", + "delivery", + "department", + "topic", + "offered_by", + "free", + "professional", + "resource_category", + "resource_type_group", +] as const + +type VectorClientFilterFacet = (typeof VECTOR_CLIENT_FILTER_FACETS)[number] + +const toUnfacetedVectorSearchParams = ( + params: ReturnType & { sortby?: string }, +): VectorSearchRequest => { + const { + offset: _offset, + limit: _limit, + ...vectorParams + } = toVectorSearchParams(params) + + return Object.fromEntries( + Object.entries(vectorParams).filter( + ([key]) => + !VECTOR_CLIENT_FILTER_FACETS.includes(key as VectorClientFilterFacet), + ), + ) as VectorSearchRequest +} + +const normalizeParamValues = (value: unknown): string[] => { + if (Array.isArray(value)) { + return value.map(String) + } + if (value === null || value === undefined || value === "") { + return [] + } + return [String(value)] +} + +const getResourceFacetValues = ( + resource: LearningResource, + facet: string, +): string[] => { + switch (facet) { + case "certification_type": + return normalizeParamValues( + "certification_type" in resource + ? resource.certification_type?.code + : undefined, + ) + case "delivery": + return normalizeParamValues( + "delivery" in resource ? resource.delivery?.map((d) => d.code) : [], + ) + case "department": + return normalizeParamValues( + resource.departments?.map((d) => d.department_id), + ) + case "offered_by": + return normalizeParamValues(resource.offered_by?.code) + case "topic": + return normalizeParamValues(resource.topics?.map((t) => t.name)) + case "free": + case "professional": + case "resource_type": + case "resource_category": + case "resource_type_group": + return normalizeParamValues(resource[facet]) + default: + return [] + } +} + +const matchesVectorClientFilters = ( + resource: LearningResource, + params: ReturnType, + excludedFacet?: string, +) => + VECTOR_CLIENT_FILTER_FACETS.every((facet) => { + if (facet === excludedFacet) { + return true + } + const selectedValues = normalizeParamValues(params[facet]) + if (selectedValues.length === 0) { + return true + } + const resourceValues = getResourceFacetValues(resource, facet) + return selectedValues.some((value) => resourceValues.includes(value)) + }) + +const hasVectorClientFilters = (params: ReturnType) => + VECTOR_CLIENT_FILTER_FACETS.some( + (facet) => normalizeParamValues(params[facet]).length > 0, + ) + +const getVectorClientAggregations = ( + allResults: LearningResource[], + params: ReturnType, + aggregationNames: string[], +) => { + return Object.fromEntries( + aggregationNames.map((name) => { + const resultsForFacet = allResults.filter((resource) => + matchesVectorClientFilters(resource, params, name), + ) + const counts = new Map() + for (const resource of resultsForFacet) { + for (const value of getResourceFacetValues(resource, name)) { + counts.set(value, (counts.get(value) ?? 0) + 1) + } + } + return [ + name, + Array.from(counts.entries()) + .map(([key, docCount]) => ({ + key, + doc_count: docCount, + })) + .sort( + (a, b) => b.doc_count - a.doc_count || a.key.localeCompare(b.key), + ), + ] + }), + ) +} + interface SearchDisplayProps { page: number setPage: (newPage: number) => void @@ -642,9 +771,17 @@ const SearchDisplay: React.FC = ({ const wantsVectorSearch = searchParams.get("vector_search") === "true" const isVectorSearch = wantsVectorSearch && user?.is_learning_path_editor + const isVectorQuerySearch = + isVectorSearch && + typeof allParams.q === "string" && + allParams.q.trim() !== "" const queryOptions = isVectorSearch - ? learningResourceQueries.vectorSearch(toVectorSearchParams(allParams)) + ? learningResourceQueries.vectorSearch( + isVectorQuerySearch + ? toUnfacetedVectorSearchParams(allParams) + : toVectorSearchParams(allParams), + ) : learningResourceQueries.search(allParams as LRSearchRequest) // @ts-expect-error Typescript has trouble unifying the different query key types @@ -690,6 +827,36 @@ const SearchDisplay: React.FC = ({ }, }) + const displayData = useMemo(() => { + if (!isVectorQuerySearch || !data) { + return data + } + + const allResults = data.results ?? [] + const results = allResults.filter((resource) => + matchesVectorClientFilters(resource, allParams), + ) + const hasClientFilters = hasVectorClientFilters(allParams) + + return { + ...data, + count: hasClientFilters ? results.length : data.count, + next: null, + previous: null, + results, + metadata: { + ...data.metadata, + aggregations: hasClientFilters + ? getVectorClientAggregations( + allResults, + allParams, + allParams.aggregations, + ) + : data.metadata.aggregations, + }, + } + }, [allParams, data, isVectorQuerySearch]) + useEffect(() => { if (onFetchTimeChange) { onFetchTimeChange(data?.time ?? null) @@ -974,7 +1141,7 @@ const SearchDisplay: React.FC = ({ facetManifest={facetManifest} activeFacets={requestParams} onFacetChange={toggleParamValue} - facetOptions={data?.metadata.aggregations ?? {}} + facetOptions={displayData?.metadata.aggregations ?? {}} /> {user?.is_learning_path_editor ? AdminOptions(expandAdminOptions, setExpandAdminOptions, adminParams) @@ -1006,14 +1173,14 @@ const SearchDisplay: React.FC = ({ * the count when data is loaded even if count is same as previous * count. */} - {isFetching || isLoading ? "" : `${data?.count} results`} + {isFetching || isLoading ? "" : `${displayData?.count} results`} setPage(1)} /> {sortDropdown} @@ -1085,9 +1252,9 @@ const SearchDisplay: React.FC = ({ ))} - ) : data && (data.results?.length ?? 0) > 0 ? ( + ) : displayData && (displayData.results?.length ?? 0) > 0 ? ( - {data.results.map((resource: LearningResource) => ( + {displayData.results.map((resource: LearningResource) => (
  • = ({ )} - { - setPage(newPage) - setTimeout(() => { - scrollHook.current?.scrollIntoView({ - block: "center", - behavior: "smooth", - }) - }, 0) - }} - renderItem={(item) => ( - - )} - /> + {!isVectorQuerySearch && ( + { + setPage(newPage) + setTimeout(() => { + scrollHook.current?.scrollIntoView({ + block: "center", + behavior: "smooth", + }) + }, 0) + }} + renderItem={(item) => ( + + )} + /> + )} diff --git a/main/settings.py b/main/settings.py index c22153921f..90e44e0657 100644 --- a/main/settings.py +++ b/main/settings.py @@ -836,6 +836,10 @@ def get_all_config_keys(): VECTOR_HYBRID_SEARCH_PREFETCH_MAX_LIMIT = get_int( name="VECTOR_HYBRID_SEARCH_PREFETCH_MAX_LIMIT", default=500 ) + +# hard limit for special cases where we need to return all results without pagination +VECTOR_SEARCH_PAGE_MAX_LIMIT = get_int("VECTOR_SEARCH_PAGE_MAX_LIMIT", 200) + # 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 95f1587eb4..bd2e324c81 100644 --- a/openapi/specs/v0.yaml +++ b/openapi/specs/v0.yaml @@ -1423,6 +1423,14 @@ paths: * `learning_material` - Learning Material description: "The category of learning resource \n\n* `course`\ \ - Course\n* `program` - Program\n* `learning_material` - Learning Material" + - in: query + name: score_cutoff + schema: + type: number + format: double + minimum: 0 + default: 0.1 + description: The minimum score a result must have to be returned - in: query name: sortby schema: diff --git a/vector_search/constants.py b/vector_search/constants.py index e740589373..2194f632f8 100644 --- a/vector_search/constants.py +++ b/vector_search/constants.py @@ -163,3 +163,6 @@ # Indexing threshold ratio QDRANT_OPTIMIZER_INDEXING_THRESHOLD_RATIO = 0.4 + +# the minimum similarity score +VECTOR_SEARCH_MIN_SCORE = 0.1 diff --git a/vector_search/serializers.py b/vector_search/serializers.py index 6e66a7687a..539bff6c8c 100644 --- a/vector_search/serializers.py +++ b/vector_search/serializers.py @@ -24,6 +24,7 @@ QDRANT_CONTENT_FILE_PARAM_MAP, QDRANT_LEARNING_RESOURCE_SORTBY_FIELDS, QDRANT_RESOURCE_PARAM_MAP, + VECTOR_SEARCH_MIN_SCORE, ) @@ -224,6 +225,12 @@ class LearningResourcesVectorSearchRequestSerializer( default=False, help_text="Whether to use a hybrid search", ) + score_cutoff = serializers.FloatField( + required=False, + default=VECTOR_SEARCH_MIN_SCORE, + min_value=0, + help_text="The minimum score a result must have to be returned", + ) def validate(self, attrs): return _validate_result_window(attrs) diff --git a/vector_search/utils.py b/vector_search/utils.py index c235595a04..344b95a48d 100644 --- a/vector_search/utils.py +++ b/vector_search/utils.py @@ -927,11 +927,13 @@ def process_batch(docs_batch, summaries_list): def _resource_vector_hits(search_result): - hits = [ - readable_id - for readable_id in (hit.payload.get("readable_id") for hit in search_result) - if readable_id - ] + hits = list( + dict.fromkeys( + readable_id + for readable_id in (hit.payload.get("readable_id") for hit in search_result) + if readable_id + ) + ) """ Always lookup learning resources by readable_id for portability in case we load points from external systems diff --git a/vector_search/utils_test.py b/vector_search/utils_test.py index e2303e8851..46cfb7f37d 100644 --- a/vector_search/utils_test.py +++ b/vector_search/utils_test.py @@ -1470,7 +1470,7 @@ def test_vector_search_hybrid(mocker, client): 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) + mock_qdrant.count.return_value = models.CountResult(count=1) mock_dense_encoder = mocker.patch("vector_search.views.dense_encoder")() mock_dense_encoder.clear_cache() diff --git a/vector_search/views.py b/vector_search/views.py index f88ac8a1a9..5494ecfec1 100644 --- a/vector_search/views.py +++ b/vector_search/views.py @@ -1,5 +1,6 @@ import asyncio import logging +from collections import Counter from functools import wraps from itertools import chain @@ -18,10 +19,13 @@ from learning_resources.constants import GROUP_CONTENT_FILE_CONTENT_VIEWERS from main.utils import cache_page_for_anonymous_users from vector_search.constants import ( + COLLECTION_PARAM_MAP, CONTENT_FILES_COLLECTION_NAME, CONTENT_FILES_RETRIEVE_PAYLOAD, + QDRANT_RESOURCE_PARAM_MAP, RESOURCES_COLLECTION_NAME, RESOURCES_RETRIEVE_PAYLOAD, + VECTOR_SEARCH_MIN_SCORE, ) from vector_search.serializers import ( ContentFileVectorSearchRequestSerializer, @@ -105,6 +109,7 @@ async def _build_search_params( # noqa: PLR0913 encoder_dense, encoder_sparse, hybrid_search, + score_cutoff: float | None, ): search_params = { "collection_name": search_collection, @@ -126,6 +131,9 @@ async def _build_search_params( # noqa: PLR0913 "limit": limit, } + if type(score_cutoff) is float and score_cutoff >= VECTOR_SEARCH_MIN_SCORE: + search_params["score_threshold"] = score_cutoff + if hybrid_search: sparse_query, dense_query = await asyncio.gather( sync_to_async(encoder_sparse.embed, thread_sensitive=False)( @@ -135,23 +143,24 @@ async def _build_search_params( # noqa: PLR0913 query_string ), ) - if order_by: + prefetch_params = [ + models.Prefetch( + filter=search_filter, + query=sparse_query, + using=encoder_sparse.model_short_name(), + limit=prefetch_limit, + ), + models.Prefetch( + filter=search_filter, + query=dense_query, + using=encoder_dense.model_short_name(), + limit=prefetch_limit, + ), + ] + if order_by and "score_threshold" not in search_params: # Nest: vector prefetches → fusion prefetch → order_by query search_params["prefetch"] = models.Prefetch( - prefetch=[ - models.Prefetch( - filter=search_filter, - query=sparse_query, - using=encoder_sparse.model_short_name(), - limit=prefetch_limit, - ), - models.Prefetch( - filter=search_filter, - query=dense_query, - using=encoder_dense.model_short_name(), - limit=prefetch_limit, - ), - ], + prefetch=prefetch_params, query=models.FusionQuery(fusion=models.Fusion.RRF), limit=prefetch_limit, ) @@ -159,20 +168,7 @@ async def _build_search_params( # noqa: PLR0913 order_by=self._format_order_by(order_by) ) else: - search_params["prefetch"] = [ - models.Prefetch( - filter=search_filter, - query=sparse_query, - using=encoder_sparse.model_short_name(), - limit=prefetch_limit, - ), - models.Prefetch( - filter=search_filter, - query=dense_query, - using=encoder_dense.model_short_name(), - limit=prefetch_limit, - ), - ] + search_params["prefetch"] = prefetch_params search_params["query"] = models.FusionQuery(fusion=models.Fusion.RRF) else: dense_query = await sync_to_async(encoder_dense.embed_query)(query_string) @@ -214,7 +210,13 @@ async def _execute_group_search(self, client, search_params, params): return search_result async def _execute_scroll_search( # noqa: PLR0913 - self, client, search_collection, search_filter, limit, offset, order_by + self, + client, + search_collection, + search_filter, + limit, + offset, + order_by, ): # Build common scroll kwargs scroll_kwargs = { @@ -257,7 +259,7 @@ async def _execute_scroll_search( # noqa: PLR0913 break return search_result[:limit] - async def async_vector_search( # noqa: PLR0913 + async def _async_vector_hits( # noqa: PLR0913 self, query_string: str, params: dict, @@ -265,9 +267,13 @@ async def async_vector_search( # noqa: PLR0913 limit: int = 10, offset: int = 0, search_collection=RESOURCES_COLLECTION_NAME, + score_cutoff: float | None = None, *, hybrid_search: bool = False, ): + """ + Execute vector search and return hydrated hits + """ client = async_qdrant_client() encoder_dense = dense_encoder() encoder_sparse = sparse_encoder() @@ -300,6 +306,7 @@ async def async_vector_search( # noqa: PLR0913 encoder_dense, encoder_sparse, hybrid_search, + score_cutoff, ) if "group_by" in params: @@ -320,18 +327,111 @@ async def async_vector_search( # noqa: PLR0913 else: # No query string — use scroll API search_result = await self._execute_scroll_search( - client, search_collection, search_filter, limit, offset, order_by + client, + search_collection, + search_filter, + limit, + offset, + order_by, ) - hits_coroutine = ( - sync_to_async(_resource_vector_hits)(search_result) - if search_collection == RESOURCES_COLLECTION_NAME - else sync_to_async(_content_file_vector_hits)(search_result) + if search_collection == RESOURCES_COLLECTION_NAME: + return await sync_to_async(_resource_vector_hits)(search_result) + else: + return await sync_to_async(_content_file_vector_hits)(search_result) + + def _extract_values(self, obj, qdrant_field): + """ + Extract values from a nested dictionary based on a path like 'topics[].name' + """ + if not qdrant_field: + return [] + + parts = qdrant_field.split(".") + values = [obj] + + for part in parts: + is_array = part.endswith("[]") + key = part[:-2] if is_array else part + + next_values = [] + for v in values: + if not isinstance(v, dict): + continue + item = v.get(key) + if item is None: + continue + + if isinstance(item, list): + next_values.extend(item) + else: + next_values.append(item) + values = next_values + + return values + + async def _async_vector_resource_counts( + self, hits, params, search_collection=RESOURCES_COLLECTION_NAME + ): + """ + Compute total count and aggregations/facets based on + hits returned from the vector search. This is a fallback + for when the aggregation counts are inaccurate + due to Qdrant's approximate search. + """ + total_count = len(hits) + aggregation_keys = params.get("aggregations") or [] + aggregations = {} + + if not aggregation_keys or not hits: + return { + "total": {"value": total_count}, + "aggregations": aggregations, + } + + param_map = COLLECTION_PARAM_MAP.get( + search_collection, QDRANT_RESOURCE_PARAM_MAP ) + for agg_key in aggregation_keys: + qdrant_field = param_map.get(agg_key) + if not qdrant_field: + continue + + counter = Counter() + for hit in hits: + seen = set() + for val in self._extract_values(hit, qdrant_field): + if isinstance(val, (str, int, float, bool)): + key = str(val).lower() if isinstance(val, bool) else str(val) + if key not in seen: + seen.add(key) + counter[key] += 1 + + aggregations[agg_key] = [ + {"key": k, "doc_count": v} for k, v in counter.most_common() + ] + + return { + "total": {"value": total_count}, + "aggregations": aggregations, + } + + async def _async_vector_counts( + self, + params: dict, + search_collection=RESOURCES_COLLECTION_NAME, + ): + """ + Compute total count and aggregations/facets + """ + client = async_qdrant_client() + search_filter = qdrant_query_conditions( + params, collection_name=search_collection + ) aggregation_keys = params.get("aggregations") or [] - hits, count_result, aggregations = await asyncio.gather( - hits_coroutine, + + count_result, aggregations = await asyncio.gather( client.count( collection_name=search_collection, count_filter=search_filter, @@ -345,11 +445,68 @@ async def async_vector_search( # noqa: PLR0913 ) return { - "hits": hits, "total": {"value": count_result.count}, "aggregations": aggregations or {}, } + async def async_vector_search( # noqa: PLR0913 + self, + query_string: str, + params: dict, + order_by: str | None = None, + limit: int = 10, + offset: int = 0, + search_collection=RESOURCES_COLLECTION_NAME, + score_cutoff: float | None = None, + *, + hybrid_search: bool = False, + ): + if ( + query_string + and type(score_cutoff) is float + and score_cutoff >= VECTOR_SEARCH_MIN_SCORE + ): + hits = await self._async_vector_hits( + query_string, + params, + order_by=order_by, + limit=settings.VECTOR_SEARCH_PAGE_MAX_LIMIT, + offset=0, + search_collection=search_collection, + score_cutoff=score_cutoff, + hybrid_search=hybrid_search, + ) + counts = await self._async_vector_resource_counts( + hits, params, search_collection=search_collection + ) + + return { + "hits": hits, + **counts, + } + + hits, counts = await asyncio.gather( + self._async_vector_hits( + query_string, + params, + order_by=order_by, + limit=limit, + offset=offset, + search_collection=search_collection, + score_cutoff=score_cutoff, + hybrid_search=hybrid_search, + ), + self._async_vector_counts( + params, + search_collection=search_collection, + ), + ) + + return { + "hits": hits, + **counts, + } + def handle_exception(self, exc): if isinstance(exc, UnexpectedResponse) and ( isinstance(exc.status_code, int) and 400 <= exc.status_code < 500 # noqa: PLR2004 @@ -398,6 +555,7 @@ async def get(self, request): offset=offset, params=request_data.data, hybrid_search=hybrid_search, + score_cutoff=request_data.data.get("score_cutoff", 0), ) if request_data.data.get("dev_mode"): return Response(response) diff --git a/vector_search/views_test.py b/vector_search/views_test.py index 9bf9b121d9..03b50c048a 100644 --- a/vector_search/views_test.py +++ b/vector_search/views_test.py @@ -1,3 +1,5 @@ +import asyncio + import pytest from django.contrib.auth.models import Group from django.urls import reverse @@ -405,10 +407,17 @@ def test_vector_search_sortby_parameter(mocker, client, query_string, hybrid_sea "q": query_string, "sortby": "-views", "hybrid_search": hybrid_search, + "score_cutoff": 0, } - - client.get( - reverse("vector_search:v0:vector_learning_resources_search"), data=params + view = QdrantView() + asyncio.run( + view.async_vector_search( + query_string, + params, + order_by="-views", + score_cutoff=0, + hybrid_search=hybrid_search, + ) ) if query_string: @@ -452,7 +461,6 @@ def test_vector_search_sortby_pagination(mocker, client): ) params = { - "q": "test", "sortby": "-created_on", "limit": 20, "offset": 60, @@ -462,7 +470,7 @@ def test_vector_search_sortby_pagination(mocker, client): reverse("vector_search:v0:vector_learning_resources_search"), data=params ) - call_kwargs = mock_qdrant.query_points.mock_calls[0].kwargs + call_kwargs = mock_qdrant.scroll.mock_calls[0].kwargs # Should request offset+limit results, not just limit assert call_kwargs["limit"] == 80 # 60 + 20 @@ -471,6 +479,42 @@ def test_vector_search_sortby_pagination(mocker, client): assert "offset" not in call_kwargs +def test_vector_search_with_score_cutoff_enforces_max_limit(mocker, client, settings): + """A query with a score cutoff should enforce VECTOR_SEARCH_PAGE_MAX_LIMIT.""" + + mock_qdrant = mocker.patch( + "qdrant_client.AsyncQdrantClient", return_value=mocker.AsyncMock() + )() + + settings.VECTOR_SEARCH_PAGE_MAX_LIMIT = 5 + + mock_result = mocker.MagicMock() + mock_result.points = [] + mock_qdrant.query_points = mocker.AsyncMock(return_value=mock_result) + mock_qdrant.scroll = mocker.AsyncMock(return_value=([], None)) + # count is no longer called in this branch + mocker.patch( + "vector_search.views.async_qdrant_client", + return_value=mock_qdrant, + ) + + params = { + "q": "test", + "limit": 10, + "offset": 20, + "score_cutoff": 0.5, + } + + client.get( + reverse("vector_search:v0:vector_learning_resources_search"), data=params + ) + + call_kwargs = mock_qdrant.query_points.mock_calls[0].kwargs + assert call_kwargs["limit"] == 5 + assert call_kwargs["offset"] == 0 + assert call_kwargs["score_threshold"] == 0.5 + + def test_vector_search_sortby_scroll_pagination(mocker, client): """Test that sortby with offset on scroll (no query) uses client-side slicing. @@ -519,3 +563,96 @@ def test_vector_search_sortby_scroll_pagination(mocker, client): # query_points should not be called (no query string) assert mock_qdrant.query_points.call_count == 0 + + +def test_async_vector_resource_counts_aggregation_buckets(): + """ + _async_vector_resource_counts should compute correct aggregation + buckets from hydrated hits that contain multi-valued fields + (topics, delivery) and scalar fields (free). + """ + view = QdrantView() + + # Simulate hydrated hits (dicts keyed by payload field paths) + hits = [ + { + "readable_id": "course-1", + "topics": [{"name": "Mathematics"}, {"name": "Physics"}], + "delivery": [{"code": "online"}, {"code": "in_person"}], + "free": True, + }, + { + "readable_id": "course-2", + "topics": [{"name": "Mathematics"}, {"name": "Social Science"}], + "delivery": [{"code": "online"}], + "free": False, + }, + { + "readable_id": "course-3", + "topics": [{"name": "Physics"}], + "delivery": [{"code": "in_person"}], + "free": True, + }, + ] + + params = {"aggregations": ["topic", "delivery", "free"]} + + result = asyncio.run( + view._async_vector_resource_counts(hits, params) # noqa: SLF001 + ) + + assert result["total"]["value"] == 3 + + # Build lookup dicts for easier assertions + topic_buckets = {b["key"]: b["doc_count"] for b in result["aggregations"]["topic"]} + delivery_buckets = { + b["key"]: b["doc_count"] for b in result["aggregations"]["delivery"] + } + free_buckets = {b["key"]: b["doc_count"] for b in result["aggregations"]["free"]} + + # topic: Mathematics appears in 2 hits, Physics in 2, Social Science in 1 + assert topic_buckets == { + "Mathematics": 2, + "Physics": 2, + "Social Science": 1, + } + + # delivery: online in 2 hits, in_person in 2 + assert delivery_buckets == {"online": 2, "in_person": 2} + + # free: True (→ "true") in 2 hits, False (→ "false") in 1 + assert free_buckets == {"true": 2, "false": 1} + + +def test_vector_search_no_score_cutoff_omits_score_threshold( + mocker, client, django_user_model +): + """Test that if score_cutoff is not explicitly passed, score_threshold is omitted from qdrant query""" + mock_qdrant = mocker.patch( + "qdrant_client.AsyncQdrantClient", return_value=mocker.AsyncMock() + )() + + mock_result = mocker.MagicMock() + mock_result.points = [] + mock_qdrant.query_points = mocker.AsyncMock(return_value=mock_result) + mock_qdrant.scroll = mocker.AsyncMock(return_value=([], None)) + mock_qdrant.count = mocker.AsyncMock(return_value=CountResult(count=42)) + mocker.patch( + "vector_search.views.async_qdrant_client", + return_value=mock_qdrant, + ) + user = django_user_model.objects.create() + group, _ = Group.objects.get_or_create(name=GROUP_CONTENT_FILE_CONTENT_VIEWERS) + group.user_set.add(user) + client.force_login(user) + + params = { + "q": "test", + "limit": 10, + "offset": 20, + } + + client.get(reverse("vector_search:v0:vector_content_files_search"), data=params) + + call_kwargs = mock_qdrant.query_points.mock_calls[0].kwargs + assert "score_threshold" not in call_kwargs From ea674f4e2f8d00f1141c197cba821ba0fd3adb95 Mon Sep 17 00:00:00 2001 From: zawan-ila <87228907+zawan-ila@users.noreply.github.com> Date: Wed, 13 May 2026 12:03:36 +0500 Subject: [PATCH 5/9] Adds a feature flag to link ocw course urls to corresponding learn urls (/courses/o/*) (#3263) --- frontends/main/src/common/feature_flags.ts | 1 + frontends/main/src/common/urls.ts | 4 + .../CallToActionSection.test.tsx | 105 ++++++++++++++++++ .../CallToActionSection.tsx | 14 +++ .../InfoSection.test.tsx | 53 +++++++++ .../LearningResourceExpanded/InfoSection.tsx | 22 +++- 6 files changed, 195 insertions(+), 4 deletions(-) diff --git a/frontends/main/src/common/feature_flags.ts b/frontends/main/src/common/feature_flags.ts index 978bcf6d12..29661aa2d3 100644 --- a/frontends/main/src/common/feature_flags.ts +++ b/frontends/main/src/common/feature_flags.ts @@ -13,6 +13,7 @@ export enum FeatureFlags { VideoShorts = "video-shorts", MitxOnlineProductPages = "mitxonline-product-pages", CourseOutlineSection = "course-outline-section", + OcwProductPages = "ocw-product-pages", VideoPlaylistPage = "video-playlist-page", PodcastDetailPage = "podcast-detail-page", } diff --git a/frontends/main/src/common/urls.ts b/frontends/main/src/common/urls.ts index d2a334a86f..f697fd5927 100644 --- a/frontends/main/src/common/urls.ts +++ b/frontends/main/src/common/urls.ts @@ -271,3 +271,7 @@ export const programPageView = (program: { : PROGRAM_PAGE_VIEW return generatePath(pattern, { readableId: program.readable_id }) } +export const ocwLearnPageView = (ocwUrl: string) => { + const url = new URL(ocwUrl) + return url.pathname.replace(/^\/courses/, "/courses/o") +} diff --git a/frontends/main/src/page-components/LearningResourceExpanded/CallToActionSection.test.tsx b/frontends/main/src/page-components/LearningResourceExpanded/CallToActionSection.test.tsx index 46dca3a62a..56fb63fabc 100644 --- a/frontends/main/src/page-components/LearningResourceExpanded/CallToActionSection.test.tsx +++ b/frontends/main/src/page-components/LearningResourceExpanded/CallToActionSection.test.tsx @@ -372,6 +372,111 @@ describe("CallToActionSection", () => { }) }) + describe("OCW product pages", () => { + const ocwSlug = + "16-01-unified-engineering-i-ii-iii-iv-fall-2005-spring-2006" + const ocwUrl = `https://ocw.mit.edu/courses/${ocwSlug}/` + + const ocwResource: typeof factories.learningResources.resource = ( + overrides, + ) => { + return factories.learningResources.resource({ + platform: { code: PlatformEnum.Ocw }, + url: ocwUrl, + ...overrides, + }) + } + + it("links to internal OCW page when flag is ON for OCW course", () => { + mockUseFeatureFlagEnabled.mockImplementation( + (flag) => flag === FeatureFlags.OcwProductPages, + ) + const resource = ocwResource({ resource_type: ResourceTypeEnum.Course }) + + renderWithProviders( + , + ) + + const link = screen.getByRole("link", { name: "Access Course Materials" }) + expect(link).toHaveAttribute("href", `/courses/o/${ocwSlug}`) + expect(link.getAttribute("href")).not.toContain("utm_") + }) + + it("uses external URL with UTM params when flag is OFF for OCW course", () => { + mockUseFeatureFlagEnabled.mockReturnValue(false) + const resource = ocwResource({ resource_type: ResourceTypeEnum.Course }) + + renderWithProviders( + , + ) + + const link = screen.getByRole("link", { name: "Access Course Materials" }) + const href = link.getAttribute("href") + expect(href).toContain("ocw.mit.edu") + expect(href).toContain("utm_source=mit-learn") + expect(href).toContain("utm_medium=referral") + }) + + it("does not transform non-OCW course URL when only OcwProductPages flag is ON", () => { + mockUseFeatureFlagEnabled.mockImplementation( + (flag) => flag === FeatureFlags.OcwProductPages, + ) + const resource = factories.learningResources.resource({ + platform: { code: PlatformEnum.Mitxonline }, + resource_type: ResourceTypeEnum.Course, + url: "https://courses.mitxonline.mit.edu/learn/course/some-course/", + }) + + renderWithProviders( + , + ) + + const link = screen.getByRole("link", { name: "Learn More" }) + const href = link.getAttribute("href") + expect(href).toContain("mitxonline.mit.edu") + expect(href).not.toContain("/courses/o/") + }) + + it("transforms OCW non-course URL when flag is ON", () => { + mockUseFeatureFlagEnabled.mockImplementation( + (flag) => flag === FeatureFlags.OcwProductPages, + ) + const resource = ocwResource({ + resource_type: ResourceTypeEnum.Video, + resource_category: "Lecture Video", + url: `${ocwUrl}resources/abc-def`, + }) + + renderWithProviders( + , + ) + + const link = screen.getByRole("link", { + name: "Watch Video", + }) + const href = link.getAttribute("href") + expect(href).toContain(`/courses/o/${ocwSlug}/resources/abc-def`) + expect(href).not.toContain("ocw.mit.edu") + expect(href).not.toContain("utm_") + }) + }) + describe("PostHog integration", () => { it("calls posthog.capture when CTA link is clicked", () => { const originalPostHogKey = process.env.NEXT_PUBLIC_POSTHOG_API_KEY diff --git a/frontends/main/src/page-components/LearningResourceExpanded/CallToActionSection.tsx b/frontends/main/src/page-components/LearningResourceExpanded/CallToActionSection.tsx index 1cb3aff9f8..6e5e2fab09 100644 --- a/frontends/main/src/page-components/LearningResourceExpanded/CallToActionSection.tsx +++ b/frontends/main/src/page-components/LearningResourceExpanded/CallToActionSection.tsx @@ -43,6 +43,7 @@ import { videoPlaylistPageView, podcastPageView, podcastEpisodePageView, + ocwLearnPageView, } from "@/common/urls" import { DisplayModeEnum } from "@mitodl/mitxonline-api-axios/v2" import { FeatureFlags } from "@/common/feature_flags" @@ -313,6 +314,7 @@ const appendUtmParams = (url?: string | null, resourceTitle?: string) => { const getResourceUrl = ( resource: LearningResource, { + ocwProductPages, mitxonlineProductPages, showVideoPlaylistPage, showPodcastPage, @@ -320,6 +322,7 @@ const getResourceUrl = ( mitxonlineProductPages?: boolean showVideoPlaylistPage?: boolean showPodcastPage?: boolean + ocwProductPages?: boolean }, ) => { if ( @@ -371,6 +374,15 @@ const getResourceUrl = ( ) } } + + if ( + ocwProductPages && + resource.platform?.code === PlatformEnum.Ocw && + resource.url + ) { + return ocwLearnPageView(resource.url) + } + return resource.url } @@ -398,6 +410,7 @@ const CallToActionSection = ({ const posthog = usePostHog() const [shareExpanded, setShareExpanded] = useState(false) const [copyText, setCopyText] = useState("Copy Link") + const ocwProductPages = useFeatureFlagEnabled(FeatureFlags.OcwProductPages) const mitxonlineProductPages = useFeatureFlagEnabled( FeatureFlags.MitxOnlineProductPages, ) @@ -435,6 +448,7 @@ const CallToActionSection = ({ mitxonlineProductPages, showVideoPlaylistPage, showPodcastPage, + ocwProductPages, }), resource.title, ) diff --git a/frontends/main/src/page-components/LearningResourceExpanded/InfoSection.test.tsx b/frontends/main/src/page-components/LearningResourceExpanded/InfoSection.test.tsx index d00bcada0c..5804a7a45b 100644 --- a/frontends/main/src/page-components/LearningResourceExpanded/InfoSection.test.tsx +++ b/frontends/main/src/page-components/LearningResourceExpanded/InfoSection.test.tsx @@ -14,6 +14,13 @@ import { } from "api" import { factories } from "api/test-utils" import { faker } from "@faker-js/faker/locale/en" +import { useFeatureFlagEnabled } from "posthog-js/react" +import { FeatureFlags } from "@/common/feature_flags" +import { ocwLearnPageView } from "@/common/urls" + +jest.mock("posthog-js/react") + +const mockedUseFeatureFlagEnabled = jest.mocked(useFeatureFlagEnabled) // This is a pipe followed by a zero-width space const SEPARATOR = "|​" @@ -36,6 +43,10 @@ const formatTestDate = (isoDate: string): string => { return date.toLocaleDateString("en-US", options) } +beforeEach(() => { + mockedUseFeatureFlagEnabled.mockReturnValue(false) +}) + describe("Learning resource info section pricing", () => { test("Free course, no certificate", () => { renderWithTheme() @@ -451,6 +462,48 @@ describe("Learning resource info section parent course", () => { const section = screen.getByTestId("drawer-info-items") expect(within(section).queryByText("Parent Course:")).toBeNull() }) + + test.each([ + { + caseName: + "uses original parent course URL when OCW product pages flag is disabled", + ocwProductPagesEnabled: false, + expectedHref: (url: string) => url, + }, + { + caseName: + "uses OCW Learn parent course URL when OCW product pages flag is enabled", + ocwProductPagesEnabled: true, + expectedHref: (url: string) => ocwLearnPageView(url), + }, + ])("$caseName", ({ ocwProductPagesEnabled, expectedHref }) => { + mockedUseFeatureFlagEnabled.mockImplementation( + (flag) => ocwProductPagesEnabled && flag === FeatureFlags.OcwProductPages, + ) + + const resource = factories.learningResources.resource({ + resource_type: ResourceTypeEnum.Document, + platform: { + code: PlatformEnum.Ocw, + name: "OCW", + }, + url: "https://ocw.mit.edu/courses/test-course", + content_files: [ + factories.learningResources.contentFile({ + run_title: "Test Course Title", + course_number: ["TEST-101"], + }), + ], + }) + + renderWithTheme() + + const link = screen.getByRole("link", { + name: "TEST-101: Test Course Title", + }) + invariant(resource.url) + expect(link).toHaveAttribute("href", expectedHref(resource.url)) + }) }) describe("Offered by section", () => { diff --git a/frontends/main/src/page-components/LearningResourceExpanded/InfoSection.tsx b/frontends/main/src/page-components/LearningResourceExpanded/InfoSection.tsx index 0413bab68f..304462c950 100644 --- a/frontends/main/src/page-components/LearningResourceExpanded/InfoSection.tsx +++ b/frontends/main/src/page-components/LearningResourceExpanded/InfoSection.tsx @@ -1,6 +1,7 @@ import React, { useState } from "react" import styled from "@emotion/styled" import ISO6391 from "iso-639-1" +import { useFeatureFlagEnabled } from "posthog-js/react" import { RemixiconComponentType, RiVerifiedBadgeLine, @@ -36,6 +37,8 @@ import { formattedParentCourseName, } from "ol-utilities" import { theme, Link } from "ol-components" +import { ocwLearnPageView } from "@/common/urls" +import { FeatureFlags } from "@/common/feature_flags" import DifferingRunsTable from "./DifferingRunsTable" const SeparatorContainer = styled.span({ @@ -145,7 +148,10 @@ const Certificate = styled.div({ }, }) -type InfoSelector = (resource: LearningResource) => React.ReactNode +type InfoSelector = ( + resource: LearningResource, + ocwProductPages?: boolean, +) => React.ReactNode type InfoItemConfig = { label: string | ((resource: LearningResource) => string) @@ -528,11 +534,17 @@ const INFO_ITEMS: InfoItemConfig = [ { label: "Parent Course:", Icon: RiBookLine, - selector: (resource: LearningResource) => { + selector: (resource: LearningResource, ocwProductPages?: boolean) => { const name = formattedParentCourseName(resource) if (!name || !resource.url) return name + + const href = + ocwProductPages && resource.platform?.code === PlatformEnum.Ocw + ? ocwLearnPageView(resource.url) + : resource.url + return ( - + {name} ) @@ -579,6 +591,8 @@ const InfoItem = ({ label, Icon, value }: InfoItemProps) => { } const InfoSection = ({ resource }: { resource?: LearningResource }) => { + const ocwProductPages = useFeatureFlagEnabled(FeatureFlags.OcwProductPages) + if (!resource) { return null } @@ -586,7 +600,7 @@ const InfoSection = ({ resource }: { resource?: LearningResource }) => { const infoItems = INFO_ITEMS.map(({ label, Icon, selector }) => ({ label: typeof label === "function" ? label(resource) : label, Icon, - value: selector(resource), + value: selector(resource, ocwProductPages), })).filter(({ value }) => value !== null && value !== "") if (infoItems.length === 0) { From ae1e3d57b1e62ad74c09df1e74b5e7223f7cd635 Mon Sep 17 00:00:00 2001 From: Carey P Gumaer Date: Wed, 13 May 2026 10:11:48 -0400 Subject: [PATCH 6/9] dashboard refactor stage 2 (#3330) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * consolidate translations card data fetching to a single view model function * just kidding, slot is fine terminology I guess * guard against the run missing on the enrollment * use existing getNativeLanguageName function which has static fallback in case Intl isn't available. * consistently order language options * refactor getDistinctDashboardLanguageOptions * delete dead language-option helpers and singular getDashboardLanguageOptions The flat rewrite of getDistinctDashboardLanguageOptions left the older singular getDashboardLanguageOptions and getDistinctLanguageOptions without production callers. Sweep them and the helpers they kept alive. dashboardViewModel.ts: - delete getDashboardLanguageOptions (singular variant — no production caller; the picker is rendered once per dashboard, not per slot) - delete getLanguageOptionFromEnrollment, getLanguageOptionKeyValue, enrollmentMatchesCourse (only used by the singular variant) - drop getDistinctLanguageOptions from the import block languageOptions.ts: - delete getDistinctLanguageOptions (no callers after the rewrite, just its own tests) - delete getLanguageOptionLabel (only used by getDistinctLanguageOptions) - delete getLanguageCodeFromOptionKey (pure dead code, no callers anywhere — written speculatively, never wired up) - unexport getLanguageOptionKey (still used internally five times, no external consumer) getEnrollableLanguageOptions and isLanguageOptionEnrollable stay; both remain load-bearing through getDefaultLanguageOptionKey and getSelectedLanguageOption, which resolveSlotForLanguage consumes. Full deletion of languageOptions.ts is committed to Phase 7 anyway. Test changes: - delete the singular-variant tests in dashboardViewModel.test.ts; adapt the union and sort assertions to call the plural getDistinctDashboardLanguageOptions with a one-element course list - delete the getDistinctLanguageOptions tests in languageOptions.test.ts - port the three Intl-fallback / memoization tests to call getNativeLanguageName directly instead of going through the now-dead getDistinctLanguageOptions, preserving edge-case coverage at ~10 lines each instead of ~60 All 28 viewModel + languageOptions tests pass; 68 dashboard render tests still pass. Coverage of languageOptions.ts: 78% -> 91%; coverage of dashboardViewModel.ts: 74% -> 96%. Co-Authored-By: Claude Opus 4.7 (1M context) * plan + languageOptions docstring: capture phase-2 retro lessons Phase 2 retro surfaced two recurring failure modes that the existing working agreement didn't activate against: - Dead code accumulating because cleanup was deferred to "later" (singular getDashboardLanguageOptions, getLanguageCodeFromOptionKey, getLanguageOptionLabel) - Composing existing helpers without auditing their premise (isLanguageOptionEnrollable filtering against missing data — the courseruns vs language_options invariant) Plan changes (working agreement): - Add "Discover dead code and bad assumptions as you go, not at the boundary" subsection between the success criterion and the phase-boundary review questions. Three inline rules: check for remaining callers when you stop calling something; check whether an existing helper already covers the case before adding one; check the premise of helpers you compose, not just their names. - The rule lives between the during-execution norms and the phase-exit ritual to make discovery part of the moment-to-moment process, not just an end-of-phase review item. languageOptions.ts docstring: - Names the load-bearing data invariant for this file: courseruns and language_options are mostly disjoint; language_options is intent, courseruns only materializes default-language runs. - Stops short of prescribing what to do about it — the fact alone is what was missing during phase 2; readers can apply skepticism themselves. - When languageOptions.ts is absorbed into dashboardViewModel.ts in phase 7, this invariant should travel with the synthesis logic. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Chris Chudzicki Co-authored-by: Claude Opus 4.7 (1M context) --- .../DashboardPage/ContractContent.tsx | 117 ++--- .../CoursewareDisplay/EnrollmentDisplay.tsx | 61 +-- .../dashboardRefactorPlan.md | 12 +- .../CoursewareDisplay/languageOptions.test.ts | 478 +----------------- .../CoursewareDisplay/languageOptions.ts | 69 +-- .../model/dashboardViewModel.test.ts | 433 ++++++++++++++++ .../model/dashboardViewModel.ts | 166 ++++++ 7 files changed, 688 insertions(+), 648 deletions(-) diff --git a/frontends/main/src/app-pages/DashboardPage/ContractContent.tsx b/frontends/main/src/app-pages/DashboardPage/ContractContent.tsx index 39fd6a7cff..1aca262678 100644 --- a/frontends/main/src/app-pages/DashboardPage/ContractContent.tsx +++ b/frontends/main/src/app-pages/DashboardPage/ContractContent.tsx @@ -35,12 +35,9 @@ import { ErrorContent } from "../ErrorPage/ErrorPageTemplate" import { matchOrganizationBySlug } from "@/common/utils" import { ResourceType, getKey } from "./CoursewareDisplay/helpers" import { - getCourseRunForSelectedLanguage, - getDistinctLanguageOptions, - getResolvedRunForSelectedLanguage, - getSelectedLanguageOption, - selectBestContractEnrollmentForLanguage, -} from "./CoursewareDisplay/languageOptions" + getDistinctDashboardLanguageOptions, + resolveSlotForLanguage, +} from "./CoursewareDisplay/model/dashboardViewModel" import UnstyledRawHTML from "@/components/UnstyledRawHTML/UnstyledRawHTML" const HeaderRoot = styled.div(({ theme }) => ({ @@ -398,54 +395,36 @@ const OrgProgramCollectionDisplay: React.FC<{ enrollments?.filter( (enrollment) => enrollment.b2b_contract_id === contract.id, ) ?? [] - // Prefer the user's existing enrollment for the selected language - // over the next/best run, so older-run enrollments stay visible - // when the contract surfaces a newer run. - const selectedLanguageEnrollment = - selectBestContractEnrollmentForLanguage( - course, - contractEnrollments, - selectedLanguageKey, - ) - const selectedLanguageOption = getSelectedLanguageOption( + const { displayedEnrollment, displayedRun } = resolveSlotForLanguage( course, + contractEnrollments, selectedLanguageKey, + { contractId: contract.id }, ) - const selectedRun = selectedLanguageEnrollment - ? ((course.courseruns ?? []).find( - (r) => r.id === selectedLanguageEnrollment.run.id, - ) ?? null) - : getCourseRunForSelectedLanguage(course, selectedLanguageKey) - const resolvedRun = getResolvedRunForSelectedLanguage( - course, - selectedLanguageOption, - selectedRun, - selectedLanguageEnrollment, - contract.id, - ) + + const resource = displayedEnrollment + ? { + type: DashboardType.CourseRunEnrollment, + data: displayedEnrollment, + } + : { type: DashboardType.Course, data: course } + return ( ) @@ -526,31 +505,20 @@ const OrgProgramDisplay: React.FC<{ courseRunEnrollments?.filter( (enrollment) => enrollment.b2b_contract_id === contract?.id, ) ?? [] - // Prefer the user's existing enrollment for the selected - // language over the next/best run, so older-run enrollments - // stay visible when the contract surfaces a newer run. - const selectedLanguageEnrollment = - selectBestContractEnrollmentForLanguage( + const { displayedEnrollment, displayedRun } = + resolveSlotForLanguage( course, contractEnrollments, selectedLanguageKey, + { contractId: contract?.id }, ) - const selectedLanguageOption = getSelectedLanguageOption( - course, - selectedLanguageKey, - ) - const selectedRun = selectedLanguageEnrollment - ? ((course.courseruns ?? []).find( - (r) => r.id === selectedLanguageEnrollment.run.id, - ) ?? null) - : getCourseRunForSelectedLanguage(course, selectedLanguageKey) - const resolvedRun = getResolvedRunForSelectedLanguage( - course, - selectedLanguageOption, - selectedRun, - selectedLanguageEnrollment, - contract?.id, - ) + + const resource = displayedEnrollment + ? { + type: DashboardType.CourseRunEnrollment, + data: displayedEnrollment, + } + : { type: DashboardType.Course, data: course } return ( ) @@ -644,8 +604,13 @@ const ContractContentInternal: React.FC = ({ [coursesQuery.data?.results], ) const languageOptions = React.useMemo( - () => getDistinctLanguageOptions(contractCourses), - [contractCourses], + () => + getDistinctDashboardLanguageOptions( + contractCourses, + courseRunEnrollmentsQuery.data ?? [], + { contractId: contract.id }, + ), + [contract.id, contractCourses, courseRunEnrollmentsQuery.data], ) const [selectedLanguageKey, setSelectedLanguageKey] = React.useState("") diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/EnrollmentDisplay.tsx b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/EnrollmentDisplay.tsx index 11a857be82..7f438cc595 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/EnrollmentDisplay.tsx +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/EnrollmentDisplay.tsx @@ -22,7 +22,6 @@ import { getRequirementsProgress, getKey, ResourceType, - selectBestEnrollment, } from "./helpers" import { DashboardCard, @@ -30,12 +29,9 @@ import { DashboardType, } from "./DashboardCard" import { - getDistinctLanguageOptions, - getSelectedLanguageOption, - getCourseRunForSelectedLanguage, - getEnrollmentForSelectedLanguage, - getResolvedRunForSelectedLanguage, -} from "./languageOptions" + getDistinctDashboardLanguageOptions, + resolveSlotForLanguage, +} from "./model/dashboardViewModel" import { coursesQueries } from "api/mitxonline-hooks/courses" import { programsQueries } from "api/mitxonline-hooks/programs" import { @@ -520,8 +516,12 @@ const ProgramEnrollmentDisplay: React.FC = ({ [programCourses?.results], ) const languageOptions = React.useMemo( - () => getDistinctLanguageOptions(allProgramCourses), - [allProgramCourses], + () => + getDistinctDashboardLanguageOptions( + allProgramCourses, + rawEnrollments ?? [], + ), + [allProgramCourses, rawEnrollments], ) const [selectedLanguageKey, setSelectedLanguageKey] = React.useState("") useEffect(() => { @@ -728,56 +728,33 @@ const ProgramEnrollmentDisplay: React.FC = ({ if (item.resourceType === "course") { const courseEnrollments = enrollmentsByCourseId[item.course.id] || [] - const selectedLanguageOption = getSelectedLanguageOption( - item.course, - selectedLanguageKey, - ) - const selectedRun = getCourseRunForSelectedLanguage( - item.course, - selectedLanguageKey, - ) - const selectedLanguageEnrollment = - getEnrollmentForSelectedLanguage( + const { displayedEnrollment, displayedRun } = + resolveSlotForLanguage( + item.course, courseEnrollments, - selectedLanguageOption, - selectedRun, + selectedLanguageKey, ) - const resolvedRun = getResolvedRunForSelectedLanguage( - item.course, - selectedLanguageOption, - selectedRun, - selectedLanguageEnrollment, - ) - // When a language is selected, only use an enrollment that - // matches that specific language run. Don't fall back to a - // different-language enrollment, which would show the wrong - // title/URL and a misleading "Continue" CTA. - const hasLanguageSelected = Boolean( - selectedLanguageKey && languageOptions.length > 0, - ) - const effectiveEnrollment = hasLanguageSelected - ? selectedLanguageEnrollment - : selectBestEnrollment(item.course, courseEnrollments) - - const resource = effectiveEnrollment + const resource = displayedEnrollment ? { type: DashboardType.CourseRunEnrollment, - data: effectiveEnrollment, + data: displayedEnrollment, } : { type: DashboardType.Course, data: item.course } + const runId = displayedEnrollment?.run.id ?? displayedRun?.id + return ( ) } diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/dashboardRefactorPlan.md b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/dashboardRefactorPlan.md index 2f6544413d..d7366bfe29 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/dashboardRefactorPlan.md +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/dashboardRefactorPlan.md @@ -18,6 +18,14 @@ This plan is not a script. Every phase is a judgment opportunity, not a checklis **Success criterion: complexity is reduced, not relocated.** The user-visible improvement of this refactor is "the dashboard is easier to reason about." That improvement is _not_ delivered by adding new directories or by colocating queries into hooks. It is delivered by **each artifact having a single, name-able responsibility that fits in your head**. A hook that composes 6 named helpers fits in your head; a hook that inlines 6 transforms does not. Moving a 700-line orchestration into a 700-line hook is a failure of this phase, even if every checkbox is ticked. Every extracted unit must be smaller, more focused, and more testable than what it replaced — or the extraction has not delivered cleanup. +**Discover dead code and bad assumptions as you go, not at the boundary.** Phase-exit review (question 2 below) is a safety net, not the primary mechanism. Surface findings in the moment: + +- **When you stop calling an existing function, check for remaining callers.** If it now has no production consumer (only tests, or nothing at all), propose deletion in the same PR. Don't leave dangling helpers for "later" — the next phase inherits more cleanup work, and reviewers can't tell intentional preservation from oversight. +- **When you introduce a new helper, check whether an existing one already covers the case.** Composing on top of a helper you should have replaced costs cleanup work twice. +- **When you read an existing helper before composing it, check whether its premise still holds.** A filter that targets data shapes the codebase no longer produces — or never produced — is dead even if it has callers. Surface it; don't propagate it. + +The cost of catching these during the phase is one extra lookup per touched symbol. The cost of catching them at phase exit is a ballooning cleanup PR and a reviewer who can't tell what's deliberate. + **Phase-boundary review questions.** At the end of every phase, the agent and reviewer answer together: 1. **Did this phase make the dashboard easier to reason about?** Concretely — what is now smaller, more isolated, or better tested? If the answer is "not really," investigate why before continuing. @@ -145,6 +153,8 @@ type HomeDashboardData = { Program and contract dashboards should use a slot model. +A **slot** is the per-course data shape the dashboard arranges into its layout — one slot per course, carrying that course plus every enrollment for it plus the row's display state (language selection, derived "displayed" enrollment/run, contract scoping, ancestor program context). Slot ≠ card: the slot is the data shape, the card is the UI that renders from it. Today one slot renders as one card; once multi-run UX lands, one slot might render as multiple cards (a dropdown, an expanded list, a dialog) without the slot itself changing. The plural is what carries the term's intuition: a program dashboard arranges N slots into its requirement layout; a contract dashboard arranges N slots per program. Home (`My Learning`) is _not_ slot-driven — it's enrollment-flat (one card per enrollment, multiple cards possible for the same course). + ```ts type DashboardCourseSlot = { course: CourseWithCourseRunsSerializerV2 @@ -265,7 +275,7 @@ This phase also folds in a small, isolated bug fix: enrolled-but-not-enrollable **Hypothesised approach** (verify before executing): - [ ] Add a composite function — working name `resolveSlotForLanguage(course, enrollments, selectedLanguageKey, { contractId })` — that returns `{ displayedEnrollment, displayedRun, selectedLanguageOption }`. Internally it calls today's primitives in the same order they are called at every callsite, so behavior is preserved by construction. **Place it in `model/dashboardViewModel.ts`** (Phase 1's pure-model home), not in `languageOptions.ts`. -- [ ] Add a composite function for the language picker — working name `getDashboardLanguageOptions(course, enrollments)` — that returns the union of enrollable V2 `course.language_options` and languages from the user's V3 `enrollment.run.language`. Also placed in `dashboardViewModel.ts`. +- [ ] Add a composite function for the language picker — working name `getDistinctDashboardLanguageOptions(courses, enrollments)` — that returns the union of V2 `course.language_options` across the given courses and languages from the user's V3 `enrollment.run.language`. Also placed in `dashboardViewModel.ts`. - [ ] Update callsites in `EnrollmentDisplay.tsx` and `ContractContent.tsx` to use the composites. The 4-call dance disappears at the callsite; render code stops knowing the orchestration exists. - [ ] Begin migrating helpers from `languageOptions.ts` into `dashboardViewModel.ts` opportunistically (anything the composites depend on internally can move). Full deletion of `languageOptions.ts` is committed to Phase 7; this phase need only consolidate what it touches. - [ ] Do not introduce a multi-run selector. diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/languageOptions.test.ts b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/languageOptions.test.ts index 79f17cb91c..c39ee8494a 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/languageOptions.test.ts +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/languageOptions.test.ts @@ -1,22 +1,14 @@ import { factories } from "api/mitxonline-test-utils" -import type { - CourseRunEnrollmentV3, - CourseRunLanguageOption, -} from "@mitodl/mitxonline-api-axios/v2" +import type { CourseRunEnrollmentV3 } from "@mitodl/mitxonline-api-axios/v2" import { getCourseRunForSelectedLanguage, - getDistinctLanguageOptions, getEnrollmentForSelectedLanguage, - getLanguageOptionKey, + getNativeLanguageName, getResolvedRunForSelectedLanguage, getSelectedLanguageOption, selectBestContractEnrollmentForLanguage, } from "./languageOptions" -type LanguageOptionWithEnrollability = CourseRunLanguageOption & { - is_enrollable: boolean -} - describe("languageOptions", () => { const setIntlDisplayNames = ( value: typeof Intl.DisplayNames | undefined, @@ -28,236 +20,11 @@ describe("languageOptions", () => { }) } - test("normalizes language keys", () => { - expect( - getLanguageOptionKey({ - id: 1, - courseware_id: "cw-1", - courseware_url: "https://example.com/cw-1", - language: "pt_BR", - title: "Run", - run_tag: "R1", - }), - ).toBe("language:pt-br") - }) - - test("builds distinct language options sorted by majority default language", () => { - const englishRunA = factories.courses.courseRun({ - id: 101, - title: "English A", - courseware_id: "cw-en-a", - courseware_url: "https://example.com/cw-en-a", - is_enrollable: true, - }) - const spanishRunA = factories.courses.courseRun({ - id: 102, - title: "Espanol A", - courseware_id: "cw-es-a", - courseware_url: "https://example.com/cw-es-a", - is_enrollable: true, - }) - const englishRunB = factories.courses.courseRun({ - id: 201, - title: "English B", - courseware_id: "cw-en-b", - courseware_url: "https://example.com/cw-en-b", - is_enrollable: true, - }) - const spanishRunB = factories.courses.courseRun({ - id: 202, - title: "Espanol B", - courseware_id: "cw-es-b", - courseware_url: "https://example.com/cw-es-b", - is_enrollable: true, - }) - const spanishRunC = factories.courses.courseRun({ - id: 302, - title: "Espanol C", - courseware_id: "cw-es-c", - courseware_url: "https://example.com/cw-es-c", - is_enrollable: true, - }) - const englishRunC = factories.courses.courseRun({ - id: 301, - title: "English C", - courseware_id: "cw-en-c", - courseware_url: "https://example.com/cw-en-c", - is_enrollable: true, - }) - - const courseA = factories.courses.course({ - courseruns: [englishRunA, spanishRunA], - next_run_id: englishRunA.id, - language_options: [ - { - id: englishRunA.id, - courseware_id: englishRunA.courseware_id, - courseware_url: englishRunA.courseware_url ?? "", - language: "en", - title: englishRunA.title, - run_tag: englishRunA.run_tag, - }, - { - id: spanishRunA.id, - courseware_id: spanishRunA.courseware_id, - courseware_url: spanishRunA.courseware_url ?? "", - language: "es", - title: spanishRunA.title, - run_tag: spanishRunA.run_tag, - }, - { - id: 999, - courseware_id: "cw-empty", - courseware_url: "https://example.com/cw-empty", - language: "", - title: "No Language", - run_tag: "R0", - }, - ], - }) - const courseB = factories.courses.course({ - courseruns: [englishRunB, spanishRunB], - next_run_id: englishRunB.id, - language_options: [ - { - id: englishRunB.id, - courseware_id: englishRunB.courseware_id, - courseware_url: englishRunB.courseware_url ?? "", - language: "en", - title: englishRunB.title, - run_tag: englishRunB.run_tag, - }, - { - id: spanishRunB.id, - courseware_id: spanishRunB.courseware_id, - courseware_url: spanishRunB.courseware_url ?? "", - language: "es", - title: spanishRunB.title, - run_tag: spanishRunB.run_tag, - }, - ], - }) - const courseC = factories.courses.course({ - courseruns: [englishRunC, spanishRunC], - next_run_id: spanishRunC.id, - language_options: [ - { - id: englishRunC.id, - courseware_id: englishRunC.courseware_id, - courseware_url: englishRunC.courseware_url ?? "", - language: "en", - title: englishRunC.title, - run_tag: englishRunC.run_tag, - }, - { - id: spanishRunC.id, - courseware_id: spanishRunC.courseware_id, - courseware_url: spanishRunC.courseware_url ?? "", - language: "es", - title: spanishRunC.title, - run_tag: spanishRunC.run_tag, - }, - ], - }) - - const options = getDistinctLanguageOptions([courseA, courseB, courseC]) - - expect(options).toHaveLength(2) - expect(options[0]).toEqual({ - value: "language:en", - label: "English", - }) - expect(options[1]).toEqual({ - value: "language:es", - label: "español", - }) - }) - - test("builds distinct options when language option ids differ from run ids", () => { - const englishRun = factories.courses.courseRun({ - id: 4001, - title: "English Run", - courseware_id: "cw-en-4001", - courseware_url: "https://example.com/cw-en-4001", - is_enrollable: true, - }) - const spanishRun = factories.courses.courseRun({ - id: 4002, - title: "Spanish Run", - courseware_id: "cw-es-4002", - courseware_url: "https://example.com/cw-es-4002", - is_enrollable: true, - }) - - const course = factories.courses.course({ - courseruns: [englishRun, spanishRun], - next_run_id: englishRun.id, - language_options: [ - { - id: 9001, - courseware_id: englishRun.courseware_id, - courseware_url: englishRun.courseware_url ?? "", - language: "en", - title: englishRun.title, - run_tag: englishRun.run_tag, - }, - { - id: 9002, - courseware_id: spanishRun.courseware_id, - courseware_url: spanishRun.courseware_url ?? "", - language: "es", - title: spanishRun.title, - run_tag: spanishRun.run_tag, - }, - ], - }) - - const options = getDistinctLanguageOptions([course]) - const selectedRun = getCourseRunForSelectedLanguage(course, "language:es") - - expect(options).toHaveLength(2) - expect(options.map((option) => option.value)).toEqual([ - "language:en", - "language:es", - ]) - expect(selectedRun?.id).toBe(spanishRun.id) - }) - - test("uses static fallback labels when Intl.DisplayNames is unavailable", () => { + test("uses static fallback label when Intl.DisplayNames is unavailable", () => { const originalDisplayNames = Intl.DisplayNames setIntlDisplayNames(undefined) - try { - const run = factories.courses.courseRun({ - id: 7001, - title: "Spanish LATAM", - courseware_id: "cw-es-419", - courseware_url: "https://example.com/cw-es-419", - is_enrollable: true, - }) - - const course = factories.courses.course({ - courseruns: [run], - next_run_id: run.id, - language_options: [ - { - id: run.id, - courseware_id: run.courseware_id, - courseware_url: run.courseware_url ?? "", - language: "es-419", - title: run.title, - run_tag: run.run_tag, - }, - ], - }) - - const options = getDistinctLanguageOptions([course]) - expect(options).toEqual([ - { - value: "language:es-419", - label: "español (Latinoamérica)", - }, - ]) + expect(getNativeLanguageName("es-419")).toBe("español (Latinoamérica)") } finally { setIntlDisplayNames(originalDisplayNames) } @@ -268,49 +35,15 @@ describe("languageOptions", () => { class MockDisplayNames { of(code: string): string | undefined { - if (code === "zz-419") { - return undefined - } - if (code === "zz") { - return "Zed" - } + if (code === "zz-419") return undefined + if (code === "zz") return "Zed" return undefined } } setIntlDisplayNames(MockDisplayNames as unknown as typeof Intl.DisplayNames) - try { - const run = factories.courses.courseRun({ - id: 7002, - title: "Mock Regional", - courseware_id: "cw-zz-419", - courseware_url: "https://example.com/cw-zz-419", - is_enrollable: true, - }) - - const course = factories.courses.course({ - courseruns: [run], - next_run_id: run.id, - language_options: [ - { - id: run.id, - courseware_id: run.courseware_id, - courseware_url: run.courseware_url ?? "", - language: "zz-419", - title: run.title, - run_tag: run.run_tag, - }, - ], - }) - - const options = getDistinctLanguageOptions([course]) - expect(options).toEqual([ - { - value: "language:zz-419", - label: "Zed", - }, - ]) + expect(getNativeLanguageName("zz-419")).toBe("Zed") } finally { setIntlDisplayNames(originalDisplayNames) } @@ -324,213 +57,22 @@ describe("languageOptions", () => { constructor() { constructorCalls += 1 } - of(code: string): string | undefined { - if (code === "es") { - return "español" - } + if (code === "es") return "español" return undefined } } setIntlDisplayNames(MockDisplayNames as unknown as typeof Intl.DisplayNames) - try { - const runA = factories.courses.courseRun({ - id: 7101, - title: "Spanish A", - courseware_id: "cw-es-7101", - courseware_url: "https://example.com/cw-es-7101", - is_enrollable: true, - }) - - const runB = factories.courses.courseRun({ - id: 7102, - title: "Spanish B", - courseware_id: "cw-es-7102", - courseware_url: "https://example.com/cw-es-7102", - is_enrollable: true, - }) - - const courseA = factories.courses.course({ - courseruns: [runA], - next_run_id: runA.id, - language_options: [ - { - id: runA.id, - courseware_id: runA.courseware_id, - courseware_url: runA.courseware_url ?? "", - language: "es", - title: runA.title, - run_tag: runA.run_tag, - }, - ], - }) - - const courseB = factories.courses.course({ - courseruns: [runB], - next_run_id: runB.id, - language_options: [ - { - id: runB.id, - courseware_id: runB.courseware_id, - courseware_url: runB.courseware_url ?? "", - language: "es", - title: runB.title, - run_tag: runB.run_tag, - }, - ], - }) - - const options = getDistinctLanguageOptions([courseA, courseB]) - - expect(options).toEqual([ - { - value: "language:es", - label: "español", - }, - ]) + expect(getNativeLanguageName("es")).toBe("español") + expect(getNativeLanguageName("es")).toBe("español") expect(constructorCalls).toBe(1) } finally { setIntlDisplayNames(originalDisplayNames) } }) - test("keeps language when one of multiple matching runs is enrollable", () => { - const englishRun = factories.courses.courseRun({ - id: 6101, - title: "English", - courseware_id: "cw-en-6101", - courseware_url: "https://example.com/cw-en-6101", - is_enrollable: true, - }) - const spanishUnenrollable = factories.courses.courseRun({ - id: 6102, - title: "Spanish Old", - courseware_id: "cw-es-shared", - courseware_url: "https://example.com/cw-es-shared", - is_enrollable: false, - }) - const spanishEnrollable = factories.courses.courseRun({ - id: 6103, - title: "Spanish New", - courseware_id: "cw-es-shared", - courseware_url: "https://example.com/cw-es-shared", - is_enrollable: true, - }) - - const course = factories.courses.course({ - courseruns: [englishRun, spanishUnenrollable, spanishEnrollable], - next_run_id: englishRun.id, - language_options: [ - { - id: 9101, - courseware_id: englishRun.courseware_id, - courseware_url: englishRun.courseware_url ?? "", - language: "en", - title: englishRun.title, - run_tag: englishRun.run_tag, - }, - { - id: 9102, - courseware_id: spanishEnrollable.courseware_id, - courseware_url: spanishEnrollable.courseware_url ?? "", - language: "es", - title: spanishEnrollable.title, - run_tag: spanishEnrollable.run_tag, - }, - ], - }) - - const options = getDistinctLanguageOptions([course]) - const selectedOption = getSelectedLanguageOption(course, "language:es") - const selectedRun = getCourseRunForSelectedLanguage(course, "language:es") - - expect(options.map((option) => option.value)).toEqual([ - "language:en", - "language:es", - ]) - expect(selectedOption).not.toBeNull() - expect(selectedRun?.id).toBe(spanishEnrollable.id) - }) - - test("includes language options even when no language variant exists in courseruns", () => { - const templateRun = factories.courses.courseRun({ - id: 6201, - title: "English Template", - courseware_id: "cw-template-en", - courseware_url: "https://example.com/cw-template-en", - is_enrollable: true, - }) - - const course = factories.courses.course({ - courseruns: [templateRun], - next_run_id: templateRun.id, - language_options: [ - { - id: templateRun.id, - courseware_id: templateRun.courseware_id, - courseware_url: templateRun.courseware_url ?? "", - language: "en", - title: templateRun.title, - run_tag: templateRun.run_tag, - }, - { - id: 6202, - courseware_id: "cw-template-es", - courseware_url: "https://example.com/cw-template-es", - language: "es", - title: "Modulo Espanol", - run_tag: templateRun.run_tag, - }, - ], - }) - - const options = getDistinctLanguageOptions([course]) - expect(options.map((option) => option.value)).toEqual([ - "language:en", - "language:es", - ]) - }) - - test("filters out unenrollable options when is_enrollable is provided", () => { - const run = factories.courses.courseRun({ - id: 6301, - title: "English", - courseware_id: "cw-en-6301", - courseware_url: "https://example.com/cw-en-6301", - is_enrollable: true, - }) - - const course = factories.courses.course({ - courseruns: [run], - next_run_id: run.id, - language_options: [ - { - id: run.id, - courseware_id: run.courseware_id, - courseware_url: run.courseware_url ?? "", - language: "en", - title: run.title, - run_tag: run.run_tag, - is_enrollable: true, - } as LanguageOptionWithEnrollability, - { - id: 6302, - courseware_id: "cw-es-6302", - courseware_url: "https://example.com/cw-es-6302", - language: "es", - title: "Modulo Espanol", - run_tag: run.run_tag, - is_enrollable: false, - } as LanguageOptionWithEnrollability, - ], - }) - - const options = getDistinctLanguageOptions([course]) - expect(options.map((option) => option.value)).toEqual(["language:en"]) - }) - test("matches selected enrollment by language option courseware id", () => { const englishRun = factories.courses.courseRun({ id: 11, diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/languageOptions.ts b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/languageOptions.ts index 05389f4d05..e5daac4e97 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/languageOptions.ts +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/languageOptions.ts @@ -1,4 +1,10 @@ -import type { SimpleSelectOption } from "ol-components" +/** + * Helpers for resolving language selection against course/enrollment data. + * + * **Note:** `course.courseruns` and `course.language_options` are + * mostly disjoint. `language_options` describes which languages the course is + * offered; `courseruns` materializes only the default-language runs. + */ import type { CourseRunEnrollmentV3, CourseRunLanguageOption, @@ -17,14 +23,6 @@ const getLanguageOptionKey = (option: CourseRunLanguageOption): string => { return languageCode ? `language:${languageCode}` : "" } -const getLanguageCodeFromOptionKey = (optionKey: string): string | null => { - if (!optionKey.startsWith("language:")) { - return null - } - const code = optionKey.replace("language:", "").trim().toLowerCase() - return code || null -} - const FALLBACK_NATIVE_LANGUAGE_NAMES: Record = { ar: "العربية", de: "Deutsch", @@ -118,15 +116,6 @@ const getNativeLanguageName = (languageCode: string): string => { return finalLabel } -const getLanguageOptionLabel = (option: CourseRunLanguageOption): string => { - const languageCode = getLanguageCode(option) - if (!languageCode) { - return "" - } - - return getNativeLanguageName(languageCode) -} - type ExtendedLanguageOption = CourseRunLanguageOption & { is_enrollable?: boolean } @@ -228,46 +217,6 @@ const getDefaultLanguageOptionKey = ( return key || null } -const getDistinctLanguageOptions = ( - courses: CourseWithCourseRunsSerializerV2[], -): SimpleSelectOption[] => { - const optionsByKey = new Map() - const defaultLanguageCounts = new Map() - - courses.forEach((course) => { - const defaultLanguageKey = getDefaultLanguageOptionKey(course) - if (defaultLanguageKey) { - defaultLanguageCounts.set( - defaultLanguageKey, - (defaultLanguageCounts.get(defaultLanguageKey) ?? 0) + 1, - ) - } - - getEnrollableLanguageOptions(course).forEach((option) => { - const key = getLanguageOptionKey(option) - const label = getLanguageOptionLabel(option) - if (!key || !label) { - return - } - if (!optionsByKey.has(key)) { - optionsByKey.set(key, { - value: key, - label, - }) - } - }) - }) - - return Array.from(optionsByKey.values()).sort((a, b) => { - const defaultCountA = defaultLanguageCounts.get(String(a.value)) ?? 0 - const defaultCountB = defaultLanguageCounts.get(String(b.value)) ?? 0 - if (defaultCountA !== defaultCountB) { - return defaultCountB - defaultCountA - } - return String(a.label).localeCompare(String(b.label)) - }) -} - const getSelectedLanguageOption = ( course: CourseWithCourseRunsSerializerV2, selectedLanguageKey: string, @@ -549,12 +498,10 @@ const getResolvedRunForSelectedLanguage = ( } export { - getLanguageCodeFromOptionKey, - getLanguageOptionKey, selectBestContractEnrollmentForLanguage, - getDistinctLanguageOptions, getSelectedLanguageOption, getCourseRunForSelectedLanguage, getEnrollmentForSelectedLanguage, getResolvedRunForSelectedLanguage, + getNativeLanguageName, } diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/model/dashboardViewModel.test.ts b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/model/dashboardViewModel.test.ts index aaf1bd9313..561d244e1a 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/model/dashboardViewModel.test.ts +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/model/dashboardViewModel.test.ts @@ -1,8 +1,10 @@ import { factories } from "api/mitxonline-test-utils" import { + getDistinctDashboardLanguageOptions, groupCourseRunEnrollmentsByCourseId, groupProgramEnrollmentsByProgramId, pickDisplayedEnrollmentForLegacyDashboard, + resolveSlotForLanguage, } from "./dashboardViewModel" describe("dashboardViewModel", () => { @@ -106,4 +108,435 @@ describe("dashboardViewModel", () => { expect(grouped[202]).toEqual(enrollment2) }) }) + + describe("getDistinctDashboardLanguageOptions", () => { + test("includes enrollment language not present in enrollable language options", () => { + const englishRun = factories.courses.courseRun({ + id: 101, + language: "en", + courseware_id: "cw-en-101", + courseware_url: "https://example.com/en-101", + is_enrollable: true, + }) + + const course = factories.courses.course({ + id: 1, + courseruns: [englishRun], + next_run_id: englishRun.id, + language_options: [ + { + id: englishRun.id, + courseware_id: englishRun.courseware_id, + courseware_url: englishRun.courseware_url ?? "", + language: "en", + title: englishRun.title, + run_tag: englishRun.run_tag, + }, + ], + }) + + const spanishEnrollment = factories.enrollment.courseEnrollment({ + b2b_contract_id: null, + run: { + id: 999, + language: "es", + title: "Modulo Espanol", + run_tag: "ES-1", + course: { id: course.id, title: course.title }, + courseware_id: "cw-es-999", + courseware_url: "https://example.com/es-999", + is_enrollable: false, + is_upgradable: false, + is_archived: false, + is_self_paced: true, + start_date: null, + end_date: null, + upgrade_deadline: null, + certificate_available_date: null, + course_number: "", + }, + }) + + const options = getDistinctDashboardLanguageOptions( + [course], + [spanishEnrollment], + ) + + expect(options.map((option) => option.value)).toEqual([ + "language:en", + "language:es", + ]) + }) + + test("adds only relevant enrollment languages for the provided course set", () => { + const courseA = factories.courses.course({ id: 10, language_options: [] }) + const courseB = factories.courses.course({ id: 20, language_options: [] }) + + const enrollmentA = factories.enrollment.courseEnrollment({ + run: { + id: 1, + language: "fr", + title: "Run FR", + run_tag: "FR-1", + course: { id: 10, title: "Course A" }, + courseware_id: "cw-fr-1", + courseware_url: "https://example.com/fr-1", + is_enrollable: true, + is_upgradable: false, + is_archived: false, + is_self_paced: true, + start_date: null, + end_date: null, + upgrade_deadline: null, + certificate_available_date: null, + course_number: "", + }, + }) + const enrollmentOtherCourse = factories.enrollment.courseEnrollment({ + run: { + id: 2, + language: "de", + title: "Run DE", + run_tag: "DE-1", + course: { id: 999, title: "Outside" }, + courseware_id: "cw-de-2", + courseware_url: "https://example.com/de-2", + is_enrollable: true, + is_upgradable: false, + is_archived: false, + is_self_paced: true, + start_date: null, + end_date: null, + upgrade_deadline: null, + certificate_available_date: null, + course_number: "", + }, + }) + + const options = getDistinctDashboardLanguageOptions( + [courseA, courseB], + [enrollmentA, enrollmentOtherCourse], + ) + + expect(options.map((option) => option.value)).toEqual(["language:fr"]) + }) + + test("sorts enrollment-derived language options by label", () => { + const course = factories.courses.course({ + id: 50, + language_options: [], + courseruns: [], + }) + + const frenchEnrollment = factories.enrollment.courseEnrollment({ + run: { + id: 500, + language: "fr", + title: "Run FR", + run_tag: "FR-1", + course: { id: 50, title: course.title }, + courseware_id: "cw-fr-500", + courseware_url: "https://example.com/fr-500", + is_enrollable: true, + is_upgradable: false, + is_archived: false, + is_self_paced: true, + start_date: null, + end_date: null, + upgrade_deadline: null, + certificate_available_date: null, + course_number: "", + }, + }) + const spanishEnrollment = factories.enrollment.courseEnrollment({ + run: { + id: 501, + language: "es", + title: "Run ES", + run_tag: "ES-1", + course: { id: 50, title: course.title }, + courseware_id: "cw-es-501", + courseware_url: "https://example.com/es-501", + is_enrollable: true, + is_upgradable: false, + is_archived: false, + is_self_paced: true, + start_date: null, + end_date: null, + upgrade_deadline: null, + certificate_available_date: null, + course_number: "", + }, + }) + + const options = getDistinctDashboardLanguageOptions( + [course], + [frenchEnrollment, spanishEnrollment], + ) + + expect(options.map((option) => option.value)).toEqual([ + "language:es", + "language:fr", + ]) + }) + + test("uses the shared native language fallback label for enrollment-derived options", () => { + const originalDisplayNames = Intl.DisplayNames + Object.defineProperty(Intl, "DisplayNames", { + value: undefined, + configurable: true, + writable: true, + }) + + try { + const course = factories.courses.course({ + id: 40, + language_options: [], + courseruns: [], + }) + const enrollment = factories.enrollment.courseEnrollment({ + run: { + id: 400, + language: "es", + title: "Spanish Run", + run_tag: "ES-1", + course: { id: 40, title: course.title }, + courseware_id: "cw-es-400", + courseware_url: "https://example.com/es-400", + is_enrollable: true, + is_upgradable: false, + is_archived: false, + is_self_paced: true, + start_date: null, + end_date: null, + upgrade_deadline: null, + certificate_available_date: null, + course_number: "", + }, + }) + + const options = getDistinctDashboardLanguageOptions( + [course], + [enrollment], + ) + + expect(options).toEqual([ + { + value: "language:es", + label: "español", + }, + ]) + } finally { + Object.defineProperty(Intl, "DisplayNames", { + value: originalDisplayNames, + configurable: true, + writable: true, + }) + } + }) + }) + + describe("resolveSlotForLanguage", () => { + test("prefers selected-language enrollment", () => { + const englishRun = factories.courses.courseRun({ + id: 11, + language: "en", + courseware_id: "cw-en-11", + courseware_url: "https://example.com/en-11", + is_enrollable: true, + }) + const spanishRun = factories.courses.courseRun({ + id: 12, + language: "es", + courseware_id: "cw-es-12", + courseware_url: "https://example.com/es-12", + is_enrollable: true, + }) + const course = factories.courses.course({ + id: 1, + courseruns: [englishRun, spanishRun], + next_run_id: englishRun.id, + language_options: [ + { + id: englishRun.id, + courseware_id: englishRun.courseware_id, + courseware_url: englishRun.courseware_url ?? "", + language: "en", + title: englishRun.title, + run_tag: englishRun.run_tag, + }, + { + id: spanishRun.id, + courseware_id: spanishRun.courseware_id, + courseware_url: spanishRun.courseware_url ?? "", + language: "es", + title: spanishRun.title, + run_tag: spanishRun.run_tag, + }, + ], + }) + + const englishEnrollment = factories.enrollment.courseEnrollment({ + run: { + id: englishRun.id, + language: "en", + course: { id: course.id, title: course.title }, + title: englishRun.title, + run_tag: englishRun.run_tag, + courseware_id: englishRun.courseware_id, + courseware_url: englishRun.courseware_url, + is_enrollable: englishRun.is_enrollable, + is_upgradable: englishRun.is_upgradable, + is_archived: englishRun.is_archived, + is_self_paced: englishRun.is_self_paced, + start_date: englishRun.start_date, + end_date: englishRun.end_date, + upgrade_deadline: englishRun.upgrade_deadline, + certificate_available_date: englishRun.certificate_available_date, + course_number: englishRun.course_number, + }, + }) + const spanishEnrollment = factories.enrollment.courseEnrollment({ + run: { + id: spanishRun.id, + language: "es", + course: { id: course.id, title: course.title }, + title: spanishRun.title, + run_tag: spanishRun.run_tag, + courseware_id: spanishRun.courseware_id, + courseware_url: spanishRun.courseware_url, + is_enrollable: spanishRun.is_enrollable, + is_upgradable: spanishRun.is_upgradable, + is_archived: spanishRun.is_archived, + is_self_paced: spanishRun.is_self_paced, + start_date: spanishRun.start_date, + end_date: spanishRun.end_date, + upgrade_deadline: spanishRun.upgrade_deadline, + certificate_available_date: spanishRun.certificate_available_date, + course_number: spanishRun.course_number, + }, + }) + + const resolved = resolveSlotForLanguage( + course, + [englishEnrollment, spanishEnrollment], + "language:es", + ) + + expect(resolved.displayedEnrollment?.run.id).toBe(spanishRun.id) + expect(resolved.displayedRun?.id).toBe(spanishRun.id) + }) + + test("does not pick enrollment from another contract", () => { + const englishRun = factories.courses.courseRun({ + id: 21, + b2b_contract: 1, + courseware_id: "cw-en-21", + courseware_url: "https://example.com/en-21", + is_enrollable: true, + }) + const spanishRun = factories.courses.courseRun({ + id: 22, + b2b_contract: 2, + courseware_id: "cw-es-22", + courseware_url: "https://example.com/es-22", + is_enrollable: true, + }) + const course = factories.courses.course({ + id: 2, + courseruns: [englishRun, spanishRun], + next_run_id: englishRun.id, + language_options: [ + { + id: englishRun.id, + courseware_id: englishRun.courseware_id, + courseware_url: englishRun.courseware_url ?? "", + language: "en", + title: englishRun.title, + run_tag: englishRun.run_tag, + }, + { + id: spanishRun.id, + courseware_id: spanishRun.courseware_id, + courseware_url: spanishRun.courseware_url ?? "", + language: "es", + title: spanishRun.title, + run_tag: spanishRun.run_tag, + }, + ], + }) + + const otherContractEnrollment = factories.enrollment.courseEnrollment({ + b2b_contract_id: 2, + run: { + id: spanishRun.id, + language: "es", + course: { id: course.id, title: course.title }, + title: spanishRun.title, + run_tag: spanishRun.run_tag, + courseware_id: spanishRun.courseware_id, + courseware_url: spanishRun.courseware_url, + is_enrollable: spanishRun.is_enrollable, + is_upgradable: spanishRun.is_upgradable, + is_archived: spanishRun.is_archived, + is_self_paced: spanishRun.is_self_paced, + start_date: spanishRun.start_date, + end_date: spanishRun.end_date, + upgrade_deadline: spanishRun.upgrade_deadline, + certificate_available_date: spanishRun.certificate_available_date, + course_number: spanishRun.course_number, + }, + }) + + const resolved = resolveSlotForLanguage( + course, + [otherContractEnrollment], + "language:es", + { contractId: 1 }, + ) + + expect(resolved.displayedEnrollment).toBeNull() + }) + + test("keeps fallback synthesized run for unenrolled selected language", () => { + const templateRun = factories.courses.courseRun({ + id: 31, + b2b_contract: 1, + courseware_id: "cw-en-31", + courseware_url: "https://example.com/en-31", + is_enrollable: true, + }) + const course = factories.courses.course({ + id: 3, + courseruns: [templateRun], + next_run_id: templateRun.id, + language_options: [ + { + id: templateRun.id, + courseware_id: templateRun.courseware_id, + courseware_url: templateRun.courseware_url ?? "", + language: "en", + title: templateRun.title, + run_tag: templateRun.run_tag, + }, + { + id: 32, + courseware_id: "cw-es-32", + courseware_url: "https://example.com/es-32", + language: "es", + title: "Modulo Espanol", + run_tag: "ES-32", + }, + ], + }) + + const resolved = resolveSlotForLanguage(course, [], "language:es", { + contractId: 1, + }) + + expect(resolved.displayedEnrollment).toBeNull() + expect(resolved.displayedRun?.id).toBe(32) + expect(resolved.displayedRun?.courseware_id).toBe("cw-es-32") + }) + }) }) diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/model/dashboardViewModel.ts b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/model/dashboardViewModel.ts index 2383f26c25..942baadbc5 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/model/dashboardViewModel.ts +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/model/dashboardViewModel.ts @@ -9,10 +9,21 @@ import type { SimpleSelectOption } from "ol-components" import type { CourseRunEnrollmentV3, + CourseRunLanguageOption, CourseRunV2, CourseWithCourseRunsSerializerV2, V3UserProgramEnrollment, } from "@mitodl/mitxonline-api-axios/v2" +import { selectBestEnrollment } from "../helpers" +import { + getNativeLanguageName, + // below five are used only for resolveSlotForLanguage + getCourseRunForSelectedLanguage, + getEnrollmentForSelectedLanguage, + getResolvedRunForSelectedLanguage, + getSelectedLanguageOption, + selectBestContractEnrollmentForLanguage, +} from "../languageOptions" /** * A program/contract dashboard's view of a course: every enrollment whose run @@ -116,8 +127,163 @@ const groupProgramEnrollmentsByProgramId = ( ) } +/** + * The mitxonline API emits language codes in POSIX form (`de_de`, + * `pt_br`, `zh_hans`); convert to BCP 47 (`de-de`, `pt-br`, `zh-hans`) + * so `Intl.DisplayNames` can resolve them, and to give picker options a + * canonical key shape. + */ +const getLanguageCodeFromText = (language: string): string => { + return language.trim().toLowerCase().replace(/_/g, "-") +} + +const filterEnrollmentsToCourses = ( + enrollments: CourseRunEnrollmentV3[], + courses: CourseWithCourseRunsSerializerV2[], +) => { + const courseIds = new Set(courses.map((course) => course.id)) + return enrollments.filter((enrollment) => + courseIds.has(enrollment.run.course.id), + ) +} + +/** + * Filter enrollments to exclusively those matching the given contractId. + * If no contractId is given, filter to enrollments with no contract. + */ +const enrollmentMatchesContract = + (contractId?: number | null) => (enrollment: CourseRunEnrollmentV3) => { + if (typeof contractId !== "number") { + return enrollment.b2b_contract_id === null + } + return enrollment.b2b_contract_id === contractId + } + +type LanguageOptionScope = { + contractId?: number +} + +const getDistinctDashboardLanguageOptions = ( + courses: CourseWithCourseRunsSerializerV2[], + enrollments: CourseRunEnrollmentV3[], + opts?: LanguageOptionScope, +): SimpleSelectOption[] => { + const coursesLanguages = courses.flatMap((c) => + c.language_options + .map((opt) => opt.language) + .filter((lang): lang is string => !!lang), + ) + const enrollmentLanguages = filterEnrollmentsToCourses(enrollments, courses) + .filter(enrollmentMatchesContract(opts?.contractId)) + .map((e) => e.run.language) + .filter((lang): lang is string => !!lang) + const distinctCodes = Array.from( + new Set( + [...coursesLanguages, ...enrollmentLanguages].map( + getLanguageCodeFromText, + ), + ), + ) + const options: SimpleSelectOption[] = distinctCodes.map((code) => { + return { + value: `language:${code}`, + label: getNativeLanguageName(code), + } + }) + options.sort((a, b) => + String(a.label).localeCompare(String(b.label), undefined, { + sensitivity: "base", + }), + ) + return options +} + +type ResolveSlotForLanguageOpts = { + contractId?: number +} + +type ResolveSlotForLanguageResult = { + displayedEnrollment: CourseRunEnrollmentV3 | null + displayedRun: CourseRunV2 | null + selectedLanguageOption: CourseRunLanguageOption | null +} + +/** + * Given a language selection and course/enrollment data, pick the + * `displayedEnrollment` and `displayedRun` for a dashboard slot. + */ +const resolveSlotForLanguage = ( + course: CourseWithCourseRunsSerializerV2, + enrollments: CourseRunEnrollmentV3[], + selectedLanguageKey: string, + opts?: ResolveSlotForLanguageOpts, +): ResolveSlotForLanguageResult => { + const selectedLanguageOption = getSelectedLanguageOption( + course, + selectedLanguageKey, + ) + const selectedRun = getCourseRunForSelectedLanguage( + course, + selectedLanguageKey, + ) + + if (typeof opts?.contractId === "number") { + const contractEnrollments = enrollments.filter( + (enrollment) => enrollment.b2b_contract_id === opts.contractId, + ) + const displayedEnrollment = selectBestContractEnrollmentForLanguage( + course, + contractEnrollments, + selectedLanguageKey, + ) + const selectedRunForResolution = displayedEnrollment + ? ((course.courseruns ?? []).find( + (run) => run.id === displayedEnrollment.run.id, + ) ?? null) + : selectedRun + + const displayedRun = getResolvedRunForSelectedLanguage( + course, + selectedLanguageOption, + selectedRunForResolution, + displayedEnrollment, + opts.contractId, + ) + + return { + displayedEnrollment, + displayedRun, + selectedLanguageOption, + } + } + + const selectedLanguageEnrollment = getEnrollmentForSelectedLanguage( + enrollments, + selectedLanguageOption, + selectedRun, + ) + const displayedEnrollment = selectedLanguageKey + ? selectedLanguageEnrollment + : selectBestEnrollment(course, enrollments) + + const displayedRun = getResolvedRunForSelectedLanguage( + course, + selectedLanguageOption, + selectedRun, + selectedLanguageEnrollment, + ) + + return { + displayedEnrollment, + displayedRun, + selectedLanguageOption, + } +} + export { pickDisplayedEnrollmentForLegacyDashboard, groupCourseRunEnrollmentsByCourseId, groupProgramEnrollmentsByProgramId, + resolveSlotForLanguage, + getDistinctDashboardLanguageOptions, } From 1021e990e221e301eddc669875d2de664de1805a Mon Sep 17 00:00:00 2001 From: Anastasia Beglova Date: Wed, 13 May 2026 14:05:55 -0400 Subject: [PATCH 7/9] youtube etl should not unpublish ovs (#3334) --- learning_resources/etl/loaders.py | 38 +++++++++++-------- learning_resources/etl/loaders_test.py | 28 +++++++++----- learning_resources/etl/pipelines.py | 4 +- learning_resources/factories.py | 3 +- .../0114_videochannel_etl_source.py | 17 +++++++++ learning_resources/models.py | 1 + learning_resources/serializers.py | 2 +- 7 files changed, 65 insertions(+), 28 deletions(-) create mode 100644 learning_resources/migrations/0114_videochannel_etl_source.py diff --git a/learning_resources/etl/loaders.py b/learning_resources/etl/loaders.py index 5a1bf1b809..c25c1610b4 100644 --- a/learning_resources/etl/loaders.py +++ b/learning_resources/etl/loaders.py @@ -1543,7 +1543,11 @@ def _upsert_ovs_playlist_from_collection(collection_data: dict) -> LearningResou playlist_data["resource_category"] = LearningResourceType.video_playlist.value channel, _ = VideoChannel.objects.get_or_create( channel_id="ovs", - defaults={"title": "ODL Video Service"}, + defaults={ + "title": "ODL Video Service", + "etl_source": ETLSource.ovs.name, + "published": True, + }, ) playlist, _ = LearningResource.objects.update_or_create( readable_id=playlist_id, @@ -1600,9 +1604,13 @@ def load_ovs_playlist(playlist_data: dict) -> LearningResource | None: LearningResource | None: the created or updated playlist resource, or None if not all videos could be matched """ - channel, _ = VideoChannel.objects.get_or_create( + channel, _ = VideoChannel.objects.update_or_create( channel_id="ovs", - defaults={"title": "ODL Video Service"}, + defaults={ + "title": "ODL Video Service", + "etl_source": ETLSource.ovs.name, + "published": True, + }, ) return load_playlist(channel, playlist_data) @@ -1797,13 +1805,6 @@ def load_videos_from_content_files( threshold = total * OCW_PLAYLIST_VIDEO_THRESHOLD if len(matched) < threshold: - log.info( - "Only %d/%d videos have matching content files (threshold: %.2f). " - "Skipping playlist.", - len(matched), - total, - OCW_PLAYLIST_VIDEO_THRESHOLD, - ) return None videos = [] @@ -1872,7 +1873,7 @@ def load_video_channel(video_channel_data: dict) -> VideoChannel: return video_channel -def load_video_channels(video_channels_data: iter) -> list[VideoChannel]: +def load_youtube_video_channels(video_channels_data: iter) -> list[VideoChannel]: """ Load a list of video channels @@ -1887,6 +1888,8 @@ def load_video_channels(video_channels_data: iter) -> list[VideoChannel]: for video_channel_data in video_channels_data: channel_id = video_channel_data["channel_id"] channel_ids.append(channel_id) + video_channel_data["etl_source"] = ETLSource.youtube.name + video_channel_data["published"] = True try: video_channel = load_video_channel(video_channel_data) except ExtractException: @@ -1902,12 +1905,17 @@ def load_video_channels(video_channels_data: iter) -> list[VideoChannel]: else: video_channels.append(video_channel) - VideoChannel.objects.exclude(channel_id__in=channel_ids).update(published=False) + VideoChannel.objects.filter(etl_source=ETLSource.youtube.name).exclude( + channel_id__in=channel_ids + ).update(published=False) # Unpublish any video playlists not included in published channels - orphaned_playlist_ids = VideoPlaylist.objects.exclude( - channel__channel_id__in=channel_ids - ).values_list("learning_resource__id", flat=True) + orphaned_playlist_ids = ( + VideoPlaylist.objects.exclude(channel__channel_id__in=channel_ids) + .filter(channel__etl_source=ETLSource.youtube.name) + .values_list("learning_resource__id", flat=True) + ) + if orphaned_playlist_ids: LearningResource.objects.filter(id__in=orphaned_playlist_ids).update( published=False diff --git a/learning_resources/etl/loaders_test.py b/learning_resources/etl/loaders_test.py index 7cd3e0e087..d397bb8a48 100644 --- a/learning_resources/etl/loaders_test.py +++ b/learning_resources/etl/loaders_test.py @@ -57,10 +57,10 @@ load_run_dependent_values, load_topics, load_video, - load_video_channels, load_video_with_content_file, load_videos, load_videos_from_content_files, + load_youtube_video_channels, ) from learning_resources.etl.mitxonline import transform_programs from learning_resources.etl.utils import get_s3_prefix_for_source @@ -2793,8 +2793,8 @@ def test_load_ovs_playlists_empty_aborts(mocker): assert vp.learning_resource.published is True -def test_load_video_channels(): - """Test load_video_channels""" +def test_load_youtube_video_channels(): + """Test load_youtube_video_channels""" assert VideoChannel.objects.count() == 0 assert VideoPlaylist.objects.count() == 0 @@ -2818,7 +2818,7 @@ def test_load_video_channels(): channel_data["playlists"] = [playlist_data] channels_data.append(channel_data) - results = load_video_channels(channels_data) + results = load_youtube_video_channels(channels_data) assert len(results) == len(channels_data) @@ -2828,7 +2828,7 @@ def test_load_video_channels(): assert result.playlists.count() == 1 -def test_load_video_channels_error(mocker): +def test_load_youtube_video_channels_error(mocker): """Test that an error doesn't fail the entire operation""" def pop_channel_id_with_exception(data): @@ -2843,17 +2843,19 @@ def pop_channel_id_with_exception(data): mock_log = mocker.patch("learning_resources.etl.loaders.log") channel_id = "abc" - load_video_channels([{"channel_id": channel_id}]) + load_youtube_video_channels([{"channel_id": channel_id}]) mock_log.exception.assert_called_once_with( "Error with extracted video channel: channel_id=%s", channel_id ) -def test_load_video_channels_unpublish(mock_upsert_tasks): - """Test load_video_channels when a video/playlist gets unpublished""" - channel = VideoChannelFactory.create() +def test_load_youtube_video_channels_unpublish(mock_upsert_tasks): + """Test load_youtube_video_channels when a video/playlist gets unpublished""" + channel = VideoChannelFactory.create(etl_source=ETLSource.youtube.name) + ovs_channel = VideoChannelFactory.create(etl_source=ETLSource.ovs.name) playlist = VideoPlaylistFactory.create(channel=channel).learning_resource + ovs_playlist = VideoPlaylistFactory.create(channel=ovs_channel).learning_resource video = VideoFactory.create().learning_resource playlist.resources.set( [video], @@ -2864,9 +2866,10 @@ def test_load_video_channels_unpublish(mock_upsert_tasks): assert channel.published is True assert video.published is True assert playlist.published is True + assert ovs_playlist.published is True # inputs don't matter here - load_video_channels([]) + load_youtube_video_channels([]) video.refresh_from_db() assert video.published is False @@ -2875,6 +2878,11 @@ def test_load_video_channels_unpublish(mock_upsert_tasks): channel.refresh_from_db() assert channel.published is False + ovs_channel.refresh_from_db() + assert ovs_channel.published is True + ovs_playlist.refresh_from_db() + assert ovs_playlist.published is True + @pytest.mark.parametrize("course_exists", [True, False]) def test_load_course_percolation( diff --git a/learning_resources/etl/pipelines.py b/learning_resources/etl/pipelines.py index 78cadb436a..f9ff50ea22 100644 --- a/learning_resources/etl/pipelines.py +++ b/learning_resources/etl/pipelines.py @@ -174,7 +174,9 @@ def ocw_courses_etl( raise ExtractException(message) -youtube_etl = compose(loaders.load_video_channels, youtube.transform, youtube.extract) +youtube_etl = compose( + loaders.load_youtube_video_channels, youtube.transform, youtube.extract +) ovs_etl = compose(loaders.load_ovs_playlists, ovs.transform, ovs.extract) diff --git a/learning_resources/factories.py b/learning_resources/factories.py index 3dc644cf1f..50f544d260 100644 --- a/learning_resources/factories.py +++ b/learning_resources/factories.py @@ -19,7 +19,7 @@ LevelType, PlatformType, ) -from learning_resources.etl.constants import CourseNumberType +from learning_resources.etl.constants import CourseNumberType, ETLSource from main.factories import UserFactory # pylint:disable=unused-argument @@ -892,6 +892,7 @@ class VideoChannelFactory(DjangoModelFactory): channel_id = factory.Sequence(lambda n: f"VIDEO-CHANNEL-{n:03d}.MIT") title = factory.Faker("word") + etl_source = FuzzyChoice((ETLSource.youtube.name, ETLSource.ovs.name)) class Params: is_unpublished = factory.Trait(published=False) diff --git a/learning_resources/migrations/0114_videochannel_etl_source.py b/learning_resources/migrations/0114_videochannel_etl_source.py new file mode 100644 index 0000000000..911018775d --- /dev/null +++ b/learning_resources/migrations/0114_videochannel_etl_source.py @@ -0,0 +1,17 @@ +# Generated by Django 4.2.30 on 2026-05-12 21:08 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("learning_resources", "0113_alter_learningresourcerelationship_options"), + ] + + operations = [ + migrations.AddField( + model_name="videochannel", + name="etl_source", + field=models.CharField(default="", max_length=12), + ), + ] diff --git a/learning_resources/models.py b/learning_resources/models.py index eb4876615e..44addac178 100644 --- a/learning_resources/models.py +++ b/learning_resources/models.py @@ -1440,6 +1440,7 @@ class VideoChannel(TimestampedModel): channel_id = models.CharField(max_length=80, primary_key=True) title = models.CharField(max_length=256) published = models.BooleanField(default=True) + etl_source = models.CharField(max_length=12, default="") def __str__(self): return f"VideoChannel: {self.title} - {self.channel_id}" diff --git a/learning_resources/serializers.py b/learning_resources/serializers.py index 58e5668034..f686cf65db 100644 --- a/learning_resources/serializers.py +++ b/learning_resources/serializers.py @@ -474,7 +474,7 @@ class VideoChannelSerializer(serializers.ModelSerializer): class Meta: model = models.VideoChannel - exclude = ["published", *COMMON_IGNORED_FIELDS] + exclude = ["published", "etl_source", *COMMON_IGNORED_FIELDS] class CaptionUrlSerializer(serializers.Serializer): From a518b3cd5b12c8f0f5d189053c17e2d87584c043 Mon Sep 17 00:00:00 2001 From: Anastasia Beglova Date: Wed, 13 May 2026 14:14:28 -0400 Subject: [PATCH 8/9] fix open textbooks (#3337) --- learning_resources/constants.py | 2 +- learning_resources/etl/loaders_test.py | 44 ++++++++++++++++++++------ 2 files changed, 35 insertions(+), 11 deletions(-) diff --git a/learning_resources/constants.py b/learning_resources/constants.py index 5eb63b3bb1..6d2d5eb1e8 100644 --- a/learning_resources/constants.py +++ b/learning_resources/constants.py @@ -154,7 +154,7 @@ class LearningResourceRelationTypes(TextChoices): "Programming Assignments": OCW_CONTENT_CATEGORY_PRACTICE_AND_ASSIGNMENT, "Activity Assignments": OCW_CONTENT_CATEGORY_PRACTICE_AND_ASSIGNMENT, "Written Assignments": OCW_CONTENT_CATEGORY_PRACTICE_AND_ASSIGNMENT, - OCW_CONTENT_CATEGORY_OPEN_TEXTBOOKS: OCW_CONTENT_CATEGORY_OPEN_TEXTBOOKS, + "Open Textbooks": OCW_CONTENT_CATEGORY_OPEN_TEXTBOOKS, } diff --git a/learning_resources/etl/loaders_test.py b/learning_resources/etl/loaders_test.py index d397bb8a48..be089ddfc7 100644 --- a/learning_resources/etl/loaders_test.py +++ b/learning_resources/etl/loaders_test.py @@ -16,6 +16,7 @@ CONTENT_TYPE_PAGE, CURRENCY_USD, OCW_CONTENT_CATEGORY_LECTURE_VIDEOS, + OCW_CONTENT_CATEGORY_OPEN_TEXTBOOKS, Availability, LearningResourceDelivery, LearningResourceRelationTypes, @@ -3296,18 +3297,25 @@ def test_load_learning_materials(mocker, settings, create_ocw_learning_materials learning_resource__is_course=True, ) - relevant_content_tag = LearningResourceContentTagFactory.create( + assignments_tag = LearningResourceContentTagFactory.create( name="Programming Assignments" ) + textbook_tag = LearningResourceContentTagFactory.create(name="Open Textbooks") irrelevant_content_tag = LearningResourceContentTagFactory.create(name="Syllabus") no_longer_relevant_resource = LearningResourceFactory.create( resource_type=LearningResourceType.document.name, ) - learning_material_content_file = ContentFileFactory.create( + assignments_content_file = ContentFileFactory.create( run=ocw_course.learning_resource.runs.first(), - content_tags=[relevant_content_tag], + content_tags=[assignments_tag], + content_type=CONTENT_TYPE_FILE, + ) + + textbook_content_file = ContentFileFactory.create( + run=ocw_course.learning_resource.runs.first(), + content_tags=[textbook_tag], content_type=CONTENT_TYPE_FILE, ) @@ -3325,24 +3333,40 @@ def test_load_learning_materials(mocker, settings, create_ocw_learning_materials loaders.load_learning_materials( course_run=ocw_course.learning_resource.runs.first(), content_file_ids=[ - learning_material_content_file.id, + assignments_content_file.id, + textbook_content_file.id, other_content_file.id, ], ) if create_ocw_learning_materials: - # Programming assignments are promoted - assert load_learning_materials_spy.call_count == 1 + # Programming assignments and Open Textbooks are promoted + assert load_learning_materials_spy.call_count == 2 load_learning_materials_spy.assert_any_call( ocw_course.learning_resource.runs.first(), - learning_material_content_file, + assignments_content_file, {"Programming Assignments"}, ) + load_learning_materials_spy.assert_any_call( + ocw_course.learning_resource.runs.first(), + textbook_content_file, + {"Open Textbooks"}, + ) resource_relationships = ocw_course.learning_resource.children.all() - assert resource_relationships.count() == 1 - # 1 load_learning_material call (calls update_index) + assert resource_relationships.count() == 2 + + # Verify resource categories + categories = set( + resource_relationships.values_list("child__resource_category", flat=True) + ) + assert categories == { + OCW_CONTENT_CATEGORY_OPEN_TEXTBOOKS, + "Practice & Assignment", + } + + # 2 load_learning_material calls (each calls update_index) # + 1 for unpublishing no_longer_relevant_resource - assert mock_index.call_count == 2 + assert mock_index.call_count == 3 no_longer_relevant_resource.refresh_from_db() assert no_longer_relevant_resource.published is False From c17fd16cb2c13cb496d30a7c5f4f03663e2810e4 Mon Sep 17 00:00:00 2001 From: Doof Date: Wed, 13 May 2026 18:15:48 +0000 Subject: [PATCH 9/9] Release 0.68.0 --- RELEASE.rst | 12 ++++++++++++ main/settings.py | 2 +- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/RELEASE.rst b/RELEASE.rst index 4f3beb35c0..7249a2b5bb 100644 --- a/RELEASE.rst +++ b/RELEASE.rst @@ -1,6 +1,18 @@ Release Notes ============= +Version 0.68.0 +-------------- + +- fix open textbooks (#3337) +- youtube etl should not unpublish ovs (#3334) +- dashboard refactor stage 2 (#3330) +- Adds a feature flag to link ocw course urls to corresponding learn urls (/courses/o/*) (#3263) +- Min score for vector learning resources endpoint (#3285) +- Update dependency granian to v2.7.4 [SECURITY] (#3309) +- Update dependency litellm to v1.83.10 [SECURITY] (#3331) +- Update dependency urllib3 to v2.7.0 [SECURITY] (#3329) + Version 0.67.2 (Released May 12, 2026) -------------- diff --git a/main/settings.py b/main/settings.py index 90e44e0657..e57562f200 100644 --- a/main/settings.py +++ b/main/settings.py @@ -35,7 +35,7 @@ from main.settings_pluggy import * # noqa: F403 from openapi.settings_spectacular import open_spectacular_settings -VERSION = "0.67.2" +VERSION = "0.68.0" log = logging.getLogger()