From d7209f6d30bc3e56061f64218b7256a88eeaf58d Mon Sep 17 00:00:00 2001 From: Shankar Ambady Date: Thu, 19 Sep 2024 14:31:11 -0400 Subject: [PATCH 01/48] Codespace opensearch service fix (#1582) * testing codespace fix * testing fix --- docker-compose.codespaces.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docker-compose.codespaces.yml b/docker-compose.codespaces.yml index 965f705a7e..8423ccce38 100644 --- a/docker-compose.codespaces.yml +++ b/docker-compose.codespaces.yml @@ -7,6 +7,8 @@ services: file: docker-compose.apps.yml service: web env_file: env/codespaces.env + depends_on: + - opensearch-node-mitopen-1 watch: extends: From 590bddd616071b81f43e573cdd6741e795e2bb2d Mon Sep 17 00:00:00 2001 From: Doof Date: Thu, 19 Sep 2024 19:25:01 +0000 Subject: [PATCH 02/48] Release 0.19.2 --- RELEASE.rst | 9 +++++++++ main/settings.py | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/RELEASE.rst b/RELEASE.rst index 8c6a4bda8a..e8739530e8 100644 --- a/RELEASE.rst +++ b/RELEASE.rst @@ -1,6 +1,15 @@ Release Notes ============= +Version 0.19.2 +-------------- + +- Codespace opensearch service fix (#1582) +- Remove learning_format, use delivery for search filter/facet (#1567) +- Signup popover owns url (#1576) +- [pre-commit.ci] pre-commit autoupdate (#1541) +- Remove an unused dependency (#1571) + Version 0.19.1 (Released September 18, 2024) -------------- diff --git a/main/settings.py b/main/settings.py index 3b3adc4d56..e3038ed7a8 100644 --- a/main/settings.py +++ b/main/settings.py @@ -33,7 +33,7 @@ from main.settings_pluggy import * # noqa: F403 from openapi.settings_spectacular import open_spectacular_settings -VERSION = "0.19.1" +VERSION = "0.19.2" log = logging.getLogger() From 1a1f5a8d4174e8da62cb372744108cc2a0f31478 Mon Sep 17 00:00:00 2001 From: Matt Bertrand Date: Fri, 20 Sep 2024 11:29:17 -0400 Subject: [PATCH 03/48] Fix extract_openedx_data and backpopulate_mit_edx_data commands to work with course/program datafiles (#1587) --- .../commands/backpopulate_mit_edx_data.py | 16 ++++++++++++---- .../management/commands/extract_openedx_data.py | 3 ++- learning_resources/tasks.py | 15 +++++++++++---- 3 files changed, 25 insertions(+), 9 deletions(-) diff --git a/learning_resources/management/commands/backpopulate_mit_edx_data.py b/learning_resources/management/commands/backpopulate_mit_edx_data.py index 3886cfe64b..77e183e185 100644 --- a/learning_resources/management/commands/backpopulate_mit_edx_data.py +++ b/learning_resources/management/commands/backpopulate_mit_edx_data.py @@ -22,9 +22,15 @@ def add_arguments(self, parser): help="Delete all existing records first", ) parser.add_argument( - "--api_datafile", - dest="api_datafile", - help="If provided, use this file as the source of API data", + "--api_course_datafile", + dest="api_course_datafile", + help="If provided, use this file as the source of course API data", + default=None, + ) + parser.add_argument( + "--api_program_datafile", + dest="api_program_datafile", + help="If provided, use this file as the source of program API data", default=None, ) super().add_arguments(parser) @@ -40,7 +46,9 @@ def handle(self, *args, **options): # noqa: ARG002 ): resource_delete_actions(learning_resource) else: - task = get_mit_edx_data.delay(options["api_datafile"]) + task = get_mit_edx_data.delay( + options["api_course_datafile"], options["api_program_datafile"] + ) self.stdout.write(f"Started task {task} to get MIT edX course data") self.stdout.write("Waiting on task...") start = now_in_utc() diff --git a/learning_resources/management/commands/extract_openedx_data.py b/learning_resources/management/commands/extract_openedx_data.py index b7f14d3fe1..84cde80f33 100644 --- a/learning_resources/management/commands/extract_openedx_data.py +++ b/learning_resources/management/commands/extract_openedx_data.py @@ -5,13 +5,14 @@ from django.core.management import BaseCommand -from learning_resources.etl import mit_edx, oll +from learning_resources.etl import mit_edx, mit_edx_programs, oll from learning_resources.etl.constants import ETLSource from main.utils import now_in_utc EXTRACTORS = { ETLSource.oll.name: oll.extract, ETLSource.mit_edx.name: mit_edx.extract, + f"{ETLSource.mit_edx.name}_programs": mit_edx_programs.extract, } diff --git a/learning_resources/tasks.py b/learning_resources/tasks.py index 3657875b89..0fd1527a3e 100644 --- a/learning_resources/tasks.py +++ b/learning_resources/tasks.py @@ -48,15 +48,22 @@ def get_micromasters_data(): @app.task -def get_mit_edx_data(api_datafile=None) -> int: +def get_mit_edx_data( + api_course_datafile: str | None = None, api_program_datafile: str | None = None +) -> int: """Task to sync MIT edX data with the database Args: - api_datafile (str): If provided, use this file as the source of API data + api_course_datafile (str): If provided, use file as source of course API data Otherwise, the API is queried directly. + api_program_datafile (str): If provided, use file as source of program API data. + Otherwise, the API is queried directly. + + Returns: + int: The number of results that were fetched """ - courses = pipelines.mit_edx_courses_etl(api_datafile) - programs = pipelines.mit_edx_programs_etl(api_datafile) + courses = pipelines.mit_edx_courses_etl(api_course_datafile) + programs = pipelines.mit_edx_programs_etl(api_program_datafile) clear_search_cache() return len(courses) + len(programs) From c849ff9bb73e31283bc32681fa113f9ec1e04b7f Mon Sep 17 00:00:00 2001 From: Shankar Ambady Date: Mon, 23 Sep 2024 11:46:36 -0400 Subject: [PATCH 04/48] follow/unfollow popover (#1589) * adding success variant * adding working version * adding working version * fixing text and adding margin to buttons * lint fixes * lint fix * fixing test cases * closing popup before posting data * removing redundancy * removing redundancy * removing empty test * updating styles * updating styles * changes to match design * changes to match design --- .../FollowPopover/FollowPopover.tsx | 144 ++++++++++++++++++ .../SearchSubscriptionToggle.test.tsx | 7 +- .../SearchSubscriptionToggle.tsx | 64 ++++---- .../SignupPopover/SignupPopover.tsx | 2 + .../src/components/Button/Button.tsx | 22 +++ .../src/components/Popover/Popover.tsx | 1 + 6 files changed, 202 insertions(+), 38 deletions(-) create mode 100644 frontends/mit-learn/src/page-components/FollowPopover/FollowPopover.tsx diff --git a/frontends/mit-learn/src/page-components/FollowPopover/FollowPopover.tsx b/frontends/mit-learn/src/page-components/FollowPopover/FollowPopover.tsx new file mode 100644 index 0000000000..3d21398f11 --- /dev/null +++ b/frontends/mit-learn/src/page-components/FollowPopover/FollowPopover.tsx @@ -0,0 +1,144 @@ +import React, { useMemo } from "react" +import { Popover, Typography, styled, Button } from "ol-components" +import type { PopoverProps } from "ol-components" +import { getSearchParamMap } from "@/common/utils" + +import { SignupPopover } from "../SignupPopover/SignupPopover" + +import { useUserMe } from "api/hooks/user" +import { SourceTypeEnum } from "api" +import { + useSearchSubscriptionCreate, + useSearchSubscriptionDelete, + useSearchSubscriptionList, +} from "api/hooks/searchSubscription" + +const StyledPopover = styled(Popover)({ + width: "300px", + maxWidth: "100vw", +}) +const HeaderText = styled(Typography)(({ theme }) => ({ + color: theme.custom.colors.darkGray2, + marginBottom: "8px", + ...theme.typography.subtitle2, +})) +const BodyText = styled(Typography)(({ theme }) => ({ + ...theme.typography.body2, + color: theme.custom.colors.silverGrayDark, + marginBottom: "16px", +})) + +const Footer = styled.div({ + display: "flex", + justifyContent: "end", + gap: "16px", +}) + +interface FollowPopoverProps + extends Pick { + itemName?: string + searchParams: URLSearchParams + sourceType: SourceTypeEnum +} + +const FollowPopover: React.FC = ({ + itemName, + searchParams, + sourceType, + ...props +}) => { + const { data: user } = useUserMe() + const subscribeParams: Record = useMemo(() => { + return { source_type: sourceType, ...getSearchParamMap(searchParams) } + }, [searchParams, sourceType]) + + const subscriptionDelete = useSearchSubscriptionDelete() + const subscriptionCreate = useSearchSubscriptionCreate() + const subscriptionList = useSearchSubscriptionList(subscribeParams, { + enabled: !!user?.is_authenticated, + }) + const unsubscribe = subscriptionDelete.mutate + const subscriptionId = subscriptionList.data?.[0]?.id + + const isSubscribed = !!subscriptionId + const handleFollowAction = async (): Promise => { + props.onClose() + if (!isSubscribed) { + await subscriptionCreate.mutateAsync({ + PercolateQuerySubscriptionRequestRequest: subscribeParams, + }) + } else { + unsubscribe(subscriptionId) + } + } + + if (user?.is_authenticated && subscriptionList.isLoading) return null + if (!user) return null + if (!user?.is_authenticated) { + return + } + + if (isSubscribed) { + return ( + + + You are following {itemName} + + + Unfollow to stop getting emails for new {itemName} courses. + +
+ + +
+
+ ) + } + return ( + + Follow {itemName}? + + You will get an email when new courses are available. + +
+ + +
+
+ ) +} + +export { FollowPopover } +export type { FollowPopoverProps } diff --git a/frontends/mit-learn/src/page-components/SearchSubscriptionToggle/SearchSubscriptionToggle.test.tsx b/frontends/mit-learn/src/page-components/SearchSubscriptionToggle/SearchSubscriptionToggle.test.tsx index c3116e8a82..844e36e2eb 100644 --- a/frontends/mit-learn/src/page-components/SearchSubscriptionToggle/SearchSubscriptionToggle.test.tsx +++ b/frontends/mit-learn/src/page-components/SearchSubscriptionToggle/SearchSubscriptionToggle.test.tsx @@ -79,6 +79,8 @@ test.each(Object.values(SourceTypeEnum))( }) await user.click(subscribeButton) + const followButton = screen.getByTestId("action-follow") + await user.click(followButton) expect(makeRequest).toHaveBeenCalledWith("post", subscribeUrl, { source_type: sourceType, offered_by: ["ocw"], @@ -111,11 +113,8 @@ test.each(Object.values(SourceTypeEnum))( }) await user.click(subscribedButton) + const unsubscribeButton = screen.getByTestId("action-unfollow") - const menu = screen.getByRole("menu") - const unsubscribeButton = within(menu).getByRole("menuitem", { - name: "Unfollow", - }) await user.click(unsubscribeButton) expect(makeRequest).toHaveBeenCalledWith( diff --git a/frontends/mit-learn/src/page-components/SearchSubscriptionToggle/SearchSubscriptionToggle.tsx b/frontends/mit-learn/src/page-components/SearchSubscriptionToggle/SearchSubscriptionToggle.tsx index b80f87e884..503b1f4cc4 100644 --- a/frontends/mit-learn/src/page-components/SearchSubscriptionToggle/SearchSubscriptionToggle.tsx +++ b/frontends/mit-learn/src/page-components/SearchSubscriptionToggle/SearchSubscriptionToggle.tsx @@ -2,15 +2,14 @@ import React, { useState, useMemo } from "react" import { getSearchParamMap } from "@/common/utils" import { useSearchSubscriptionCreate, - useSearchSubscriptionDelete, useSearchSubscriptionList, } from "api/hooks/searchSubscription" -import { Button, SimpleMenu, styled } from "ol-components" -import type { SimpleMenuItem } from "ol-components" -import { RiArrowDownSLine, RiMailLine } from "@remixicon/react" +import { Button, styled } from "ol-components" + +import { RiMailLine } from "@remixicon/react" import { useUserMe } from "api/hooks/user" import { SourceTypeEnum } from "api" -import { SignupPopover } from "../SignupPopover/SignupPopover" +import { FollowPopover } from "../FollowPopover/FollowPopover" const StyledButton = styled(Button)({ minWidth: "130px", @@ -23,6 +22,7 @@ type SearchSubscriptionToggleProps = { } const SearchSubscriptionToggle: React.FC = ({ + itemName, searchParams, sourceType, }) => { @@ -33,35 +33,17 @@ const SearchSubscriptionToggle: React.FC = ({ }, [searchParams, sourceType]) const { data: user } = useUserMe() - const subscriptionDelete = useSearchSubscriptionDelete() + const subscriptionCreate = useSearchSubscriptionCreate() const subscriptionList = useSearchSubscriptionList(subscribeParams, { enabled: !!user?.is_authenticated, }) - const unsubscribe = subscriptionDelete.mutate const subscriptionId = subscriptionList.data?.[0]?.id const isSubscribed = !!subscriptionId - const unsubscribeItems: SimpleMenuItem[] = useMemo(() => { - if (!subscriptionId) return [] - return [ - { - key: "unsubscribe", - label: "Unfollow", - onClick: () => unsubscribe(subscriptionId), - }, - ] - }, [unsubscribe, subscriptionId]) - const onFollowClick = async (event: React.MouseEvent) => { - if (user?.is_authenticated) { - await subscriptionCreate.mutateAsync({ - PercolateQuerySubscriptionRequestRequest: subscribeParams, - }) - } else { - setButtonEl(event.currentTarget) - } + setButtonEl(event.currentTarget) } if (user?.is_authenticated && subscriptionList.isLoading) return null @@ -69,14 +51,22 @@ const SearchSubscriptionToggle: React.FC = ({ if (isSubscribed) { return ( - }> - Following - - } - items={unsubscribeItems} - /> + <> + } + > + Following + + setButtonEl(null)} + /> + ) } @@ -90,7 +80,13 @@ const SearchSubscriptionToggle: React.FC = ({ > Follow - setButtonEl(null)} /> + setButtonEl(null)} + /> ) } diff --git a/frontends/mit-learn/src/page-components/SignupPopover/SignupPopover.tsx b/frontends/mit-learn/src/page-components/SignupPopover/SignupPopover.tsx index 64487d1b25..03100dabeb 100644 --- a/frontends/mit-learn/src/page-components/SignupPopover/SignupPopover.tsx +++ b/frontends/mit-learn/src/page-components/SignupPopover/SignupPopover.tsx @@ -11,10 +11,12 @@ const StyledPopover = styled(Popover)({ const HeaderText = styled(Typography)(({ theme }) => ({ color: theme.custom.colors.darkGray2, marginBottom: "8px", + ...theme.typography.subtitle2, })) const BodyText = styled(Typography)(({ theme }) => ({ color: theme.custom.colors.silverGrayDark, marginBottom: "16px", + ...theme.typography.body2, })) const Footer = styled.div({ diff --git a/frontends/ol-components/src/components/Button/Button.tsx b/frontends/ol-components/src/components/Button/Button.tsx index ac88f0db5d..641e264f18 100644 --- a/frontends/ol-components/src/components/Button/Button.tsx +++ b/frontends/ol-components/src/components/Button/Button.tsx @@ -12,6 +12,7 @@ type ButtonVariant = | "text" | "noBorder" | "inverted" + | "success" type ButtonSize = "small" | "medium" | "large" type ButtonEdge = "circular" | "rounded" | "none" @@ -127,6 +128,27 @@ const ButtonStyled = styled.button((props) => { borderColor: "currentcolor", borderStyle: "solid", }, + variant === "success" && { + backgroundColor: colors.darkGreen, + color: colors.white, + border: "none", + /* Shadow/04dp */ + boxShadow: + "0px 2px 4px 0px rgba(37, 38, 43, 0.10), 0px 3px 8px 0px rgba(37, 38, 43, 0.12)", + ":hover:not(:disabled)": { + backgroundColor: colors.darkGreen, + boxShadow: "none", + }, + ":disabled": { + backgroundColor: colors.silverGray, + boxShadow: "none", + }, + }, + hasBorder && { + backgroundColor: "transparent", + borderColor: "currentcolor", + borderStyle: "solid", + }, variant === "secondary" && { color: colors.red, ":hover:not(:disabled)": { diff --git a/frontends/ol-components/src/components/Popover/Popover.tsx b/frontends/ol-components/src/components/Popover/Popover.tsx index 48a332c227..39e96ff8af 100644 --- a/frontends/ol-components/src/components/Popover/Popover.tsx +++ b/frontends/ol-components/src/components/Popover/Popover.tsx @@ -128,6 +128,7 @@ const Arrow = styled("div")({ const Content = styled.div(({ theme }) => ({ padding: "16px", backgroundColor: theme.custom.colors.white, + borderRadius: "8px", boxShadow: "0px 2px 4px 0px rgba(37, 38, 43, 0.10), 0px 6px 24px 0px rgba(37, 38, 43, 0.24)", })) From 9e9fc0745b287058f06963d8324bd45935cdf19b Mon Sep 17 00:00:00 2001 From: Anastasia Beglova Date: Mon, 23 Sep 2024 12:18:31 -0400 Subject: [PATCH 05/48] Make search mode defaults settable env variables (#1590) --- app.json | 20 +++++++ frontends/mit-learn/jest.config.ts | 5 ++ .../SearchDisplay/SearchDisplay.tsx | 29 +++++++--- .../src/pages/SearchPage/SearchPage.test.tsx | 39 ++++++++----- frontends/mit-learn/webpack.config.js | 30 ++++++++++ .../ol-utilities/src/types/settings.d.ts | 5 ++ learning_resources_search/api.py | 30 +++++++++- learning_resources_search/api_test.py | 58 ++++++++++++++++++- learning_resources_search/serializers.py | 3 - main/settings.py | 13 +++++ openapi/specs/v1.yaml | 15 ----- 11 files changed, 204 insertions(+), 43 deletions(-) diff --git a/app.json b/app.json index e47e0e6f8e..4fe3feccae 100644 --- a/app.json +++ b/app.json @@ -71,6 +71,26 @@ "description": "The domain to set the CSRF cookie on", "required": false }, + "DEFAULT_SEARCH_MODE": { + "description": "Default search mode for the search API and frontend", + "required": false + }, + "DEFAULT_SEARCH_SLOP": { + "description": "Default slop value for the search API and frontend. Only used for phrase queries.", + "required": false + }, + "DEFAULT_SEARCH_STALENESS_PENALTY": { + "description": "Default staleness penalty value for the search API and frontend", + "required": false + }, + "DEFAULT_SEARCH_MINIMUM_SCORE_CUTOFF": { + "description": "Default minimum score cutoff value for the search API and frontend", + "required": false + }, + "DEFAULT_SEARCH_MAX_INCOMPLETENESS_PENALTY": { + "description": "Default max incompleteness penalty value for the search API and frontend", + "required": false + }, "EDX_API_ACCESS_TOKEN_URL": { "description": "URL to retrieve a MITx access token", "required": false diff --git a/frontends/mit-learn/jest.config.ts b/frontends/mit-learn/jest.config.ts index c767e8583f..54925827a7 100644 --- a/frontends/mit-learn/jest.config.ts +++ b/frontends/mit-learn/jest.config.ts @@ -19,6 +19,11 @@ const config: Config.InitialOptions = { MITOL_API_BASE_URL: "https://api.test.learn.mit.edu", PUBLIC_URL: "", SITE_NAME: "MIT Learn", + DEFAULT_SEARCH_MODE: "phrase", + DEFAULT_SEARCH_SLOP: 6, + DEFAULT_SEARCH_STALENESS_PENALTY: 2.5, + DEFAULT_SEARCH_MINIMUM_SCORE_CUTOFF: 0, + DEFAULT_SEARCH_MAX_INCOMPLETENESS_PENALTY: 90, }, }, } diff --git a/frontends/mit-learn/src/page-components/SearchDisplay/SearchDisplay.tsx b/frontends/mit-learn/src/page-components/SearchDisplay/SearchDisplay.tsx index d269fa71ae..7c27760057 100644 --- a/frontends/mit-learn/src/page-components/SearchDisplay/SearchDisplay.tsx +++ b/frontends/mit-learn/src/page-components/SearchDisplay/SearchDisplay.tsx @@ -172,6 +172,11 @@ const FacetStyles = styled.div` &.facets-expanded { max-height: 600px; + + &.admin-facet { + max-height: fit-content; + } + transition: max-height 0.4s ease-in; } @@ -515,6 +520,14 @@ interface SearchDisplayProps { filterHeadingEl: React.ElementType } +const { + DEFAULT_SEARCH_MODE, + DEFAULT_SEARCH_SLOP, + DEFAULT_SEARCH_STALENESS_PENALTY, + DEFAULT_SEARCH_MINIMUM_SCORE_CUTOFF, + DEFAULT_SEARCH_MAX_INCOMPLETENESS_PENALTY, +} = APP_SETTINGS + const SearchDisplay: React.FC = ({ page, setPage, @@ -585,7 +598,7 @@ const SearchDisplay: React.FC = ({ const searchModeDropdown = ( setSearchParams((prev) => { const next = new URLSearchParams(prev) @@ -623,7 +636,7 @@ const SearchDisplay: React.FC = ({ return (
- {searchParams.get("search_mode") === "phrase" ? ( + {(!searchParams.get("search_mode") && + DEFAULT_SEARCH_MODE === "phrase") || + searchParams.get("search_mode") === "phrase" ? (
Slop @@ -674,7 +689,7 @@ const SearchDisplay: React.FC = ({ currentValue={ searchParams.get("slop") ? Number(searchParams.get("slop")) - : 0 + : DEFAULT_SEARCH_SLOP } setSearchParams={setSearchParams} urlParam="slop" @@ -694,7 +709,7 @@ const SearchDisplay: React.FC = ({ currentValue={ searchParams.get("min_score") ? Number(searchParams.get("min_score")) - : 0 + : DEFAULT_SEARCH_MINIMUM_SCORE_CUTOFF } setSearchParams={setSearchParams} urlParam="min_score" @@ -713,7 +728,7 @@ const SearchDisplay: React.FC = ({ currentValue={ searchParams.get("max_incompleteness_penalty") ? Number(searchParams.get("max_incompleteness_penalty")) - : 0 + : DEFAULT_SEARCH_MAX_INCOMPLETENESS_PENALTY } setSearchParams={setSearchParams} urlParam="max_incompleteness_penalty" diff --git a/frontends/mit-learn/src/pages/SearchPage/SearchPage.test.tsx b/frontends/mit-learn/src/pages/SearchPage/SearchPage.test.tsx index 4a5fae0258..697d3f9ae5 100644 --- a/frontends/mit-learn/src/pages/SearchPage/SearchPage.test.tsx +++ b/frontends/mit-learn/src/pages/SearchPage/SearchPage.test.tsx @@ -306,6 +306,11 @@ describe("SearchPage", () => { setMockResponse.get(urls.userMe.get(), { is_learning_path_editor: true, }) + APP_SETTINGS.DEFAULT_SEARCH_MODE = "phrase" + APP_SETTINGS.DEFAULT_SEARCH_SLOP = 6 + APP_SETTINGS.DEFAULT_SEARCH_STALENESS_PENALTY = 2.5 + APP_SETTINGS.DEFAULT_SEARCH_MINIMUM_SCORE_CUTOFF = 0 + APP_SETTINGS.DEFAULT_SEARCH_MAX_INCOMPLETENESS_PENALTY = 90 renderWithProviders() await waitFor(() => { const adminFacetContainer = screen.getByText("Admin Options") @@ -321,6 +326,11 @@ describe("SearchPage", () => { }) test("admin users can set the search mode and slop", async () => { + APP_SETTINGS.DEFAULT_SEARCH_MODE = "phrase" + APP_SETTINGS.DEFAULT_SEARCH_SLOP = 6 + APP_SETTINGS.DEFAULT_SEARCH_STALENESS_PENALTY = 2.5 + APP_SETTINGS.DEFAULT_SEARCH_MINIMUM_SCORE_CUTOFF = 0 + APP_SETTINGS.DEFAULT_SEARCH_MAX_INCOMPLETENESS_PENALTY = 90 setMockApiResponses({ search: { count: 700, @@ -349,14 +359,24 @@ test("admin users can set the search mode and slop", async () => { user.click(adminFacetContainer) }) - let slopSlider = screen.queryByText("Slop") - expect(slopSlider).toBeNull() - - const searchModeDropdowns = await screen.findAllByText("best_fields") + const searchModeDropdowns = await screen.findAllByText("phrase") const searchModeDropdown = searchModeDropdowns[0] await user.click(searchModeDropdown) + const mostFieldsSelect = await screen.findByRole("option", { + name: "most_fields", + }) + + await user.click(mostFieldsSelect) + + expect(location.current.search).toBe("?search_mode=most_fields") + + const slopSlider = screen.queryByText("Slop") + expect(slopSlider).toBeNull() + + await user.click(searchModeDropdown) + const phraseSelect = await screen.findByRole("option", { name: "phrase", }) @@ -370,17 +390,6 @@ test("admin users can set the search mode and slop", async () => { }) await user.click(searchModeDropdown) - - const mostFieldsSelect = await screen.findByRole("option", { - name: "most_fields", - }) - - await user.click(mostFieldsSelect) - - expect(location.current.search).toBe("?search_mode=most_fields") - - slopSlider = screen.queryByText("Slop") - expect(slopSlider).toBeNull() }) describe("Search Page Tabs", () => { diff --git a/frontends/mit-learn/webpack.config.js b/frontends/mit-learn/webpack.config.js index 7e49fd3bbb..9d48b30bd0 100644 --- a/frontends/mit-learn/webpack.config.js +++ b/frontends/mit-learn/webpack.config.js @@ -45,6 +45,11 @@ const { CSRF_COOKIE_NAME, APPZI_URL, MITOL_NOINDEX, + DEFAULT_SEARCH_MODE, + DEFAULT_SEARCH_SLOP, + DEFAULT_SEARCH_STALENESS_PENALTY, + DEFAULT_SEARCH_MINIMUM_SCORE_CUTOFF, + DEFAULT_SEARCH_MAX_INCOMPLETENESS_PENALTY, } = cleanEnv(process.env, { NODE_ENV: str({ choices: ["development", "production", "test"], @@ -124,6 +129,26 @@ const { desc: "Whether to include a noindex meta tag", default: true, }), + DEFAULT_SEARCH_SLOP: num({ + desc: "The default search slop", + default: 6, + }), + DEFAULT_SEARCH_STALENESS_PENALTY: num({ + desc: "The default search staleness penalty", + default: 2.5, + }), + DEFAULT_SEARCH_MINIMUM_SCORE_CUTOFF: num({ + desc: "The default search minimum score cutoff", + default: 0, + }), + DEFAULT_SEARCH_MAX_INCOMPLETENESS_PENALTY: num({ + desc: "The default search max incompleteness penalty", + default: 90, + }), + DEFAULT_SEARCH_MODE: str({ + desc: "The default search mode", + default: "phrase", + }), }) const MITOL_FEATURES_PREFIX = "FEATURE_" @@ -265,6 +290,11 @@ module.exports = (env, argv) => { MITOL_SUPPORT_EMAIL: JSON.stringify(MITOL_SUPPORT_EMAIL), PUBLIC_URL: JSON.stringify(PUBLIC_URL), CSRF_COOKIE_NAME: JSON.stringify(CSRF_COOKIE_NAME), + DEFAULT_SEARCH_MODE: JSON.stringify(DEFAULT_SEARCH_MODE), + DEFAULT_SEARCH_MAX_INCOMPLETENESS_PENALTY, + DEFAULT_SEARCH_MINIMUM_SCORE_CUTOFF, + DEFAULT_SEARCH_SLOP, + DEFAULT_SEARCH_STALENESS_PENALTY, }, }), ] diff --git a/frontends/ol-utilities/src/types/settings.d.ts b/frontends/ol-utilities/src/types/settings.d.ts index da51840dd7..553c75e0c8 100644 --- a/frontends/ol-utilities/src/types/settings.d.ts +++ b/frontends/ol-utilities/src/types/settings.d.ts @@ -22,5 +22,10 @@ export declare global { SITE_NAME: string MITOL_SUPPORT_EMAIL: string PUBLIC_URL: string + DEFAULT_SEARCH_MODE: string + DEFAULT_SEARCH_SLOP: number + DEFAULT_SEARCH_STALENESS_PENALTY: number + DEFAULT_SEARCH_MINIMUM_SCORE_CUTOFF: number + DEFAULT_SEARCH_MAX_INCOMPLETENESS_PENALTY: number } } diff --git a/learning_resources_search/api.py b/learning_resources_search/api.py index eb4582a581..f6e08a07d1 100644 --- a/learning_resources_search/api.py +++ b/learning_resources_search/api.py @@ -5,6 +5,7 @@ from collections import Counter from datetime import UTC, datetime +from django.conf import settings from opensearch_dsl import Search from opensearch_dsl.query import MoreLikeThis, Percolate from opensearchpy.exceptions import NotFoundError @@ -507,7 +508,19 @@ def adjust_original_query_for_percolate(query): Remove keys that are irrelevent when storing original queries for percolate uniqueness such as "limit" and "offset" """ - for key in ["limit", "offset", "sortby", "yearly_decay_percent", "dev_mode"]: + for key in [ + "limit", + "offset", + "sortby", + "yearly_decay_percent", + "dev_mode", + "use_dfs_query_then_fetch", + "max_incompleteness_penalty", + "min_score", + "search_mode", + "slop", + "use_dfs_query_then_fetch", + ]: query.pop(key, None) return order_params(query) @@ -684,6 +697,21 @@ def execute_learn_search(search_params): Returns: dict: The opensearch response dict """ + if search_params.get("endpoint") != CONTENT_FILE_TYPE: + if search_params.get("yearly_decay_percent") is None: + search_params["yearly_decay_percent"] = ( + settings.DEFAULT_SEARCH_STALENESS_PENALTY + ) + if search_params.get("search_mode") is None: + search_params["search_mode"] = settings.DEFAULT_SEARCH_MODE + if search_params.get("slop") is None: + search_params["slop"] = settings.DEFAULT_SEARCH_SLOP + if search_params.get("min_score") is None: + search_params["min_score"] = settings.DEFAULT_SEARCH_MINIMUM_SCORE_CUTOFF + if search_params.get("max_incompleteness_penalty") is None: + search_params["max_incompleteness_penalty"] = ( + settings.DEFAULT_SEARCH_MAX_INCOMPLETENESS_PENALTY + ) search = construct_search(search_params) return search.execute().to_dict() diff --git a/learning_resources_search/api_test.py b/learning_resources_search/api_test.py index 71282429f5..86b810113b 100644 --- a/learning_resources_search/api_test.py +++ b/learning_resources_search/api_test.py @@ -1051,6 +1051,10 @@ def test_execute_learn_search_for_learning_resource_query(opensearch): "offset": 1, "sortby": "-readable_id", "endpoint": LEARNING_RESOURCE, + "yearly_decay_percent": 0, + "max_incompleteness_penalty": 0, + "min_score": 0, + "search_mode": "best_fields", } query = { @@ -1079,6 +1083,7 @@ def test_execute_learn_search_for_learning_resource_query(opensearch): "course_feature", "video.transcript.english", ], + "type": "best_fields", } }, { @@ -1090,6 +1095,7 @@ def test_execute_learn_search_for_learning_resource_query(opensearch): "fields": [ "topics.name" ], + "type": "best_fields", } }, } @@ -1104,6 +1110,7 @@ def test_execute_learn_search_for_learning_resource_query(opensearch): "departments.department_id", "departments.name", ], + "type": "best_fields", } }, } @@ -1117,6 +1124,7 @@ def test_execute_learn_search_for_learning_resource_query(opensearch): "fields": [ "course.course_numbers.value" ], + "type": "best_fields", } }, } @@ -1132,6 +1140,7 @@ def test_execute_learn_search_for_learning_resource_query(opensearch): "runs.semester", "runs.level", ], + "type": "best_fields", } }, } @@ -1150,6 +1159,7 @@ def test_execute_learn_search_for_learning_resource_query(opensearch): "runs.instructors.last_name^5", "runs.instructors.full_name^5", ], + "type": "best_fields", } }, } @@ -1168,6 +1178,7 @@ def test_execute_learn_search_for_learning_resource_query(opensearch): "short_description.english^2", "content_feature_type", ], + "type": "best_fields", } }, "score_mode": "avg", @@ -1194,6 +1205,7 @@ def test_execute_learn_search_for_learning_resource_query(opensearch): "course_feature", "video.transcript.english", ], + "type": "best_fields", } }, { @@ -1203,6 +1215,7 @@ def test_execute_learn_search_for_learning_resource_query(opensearch): "multi_match": { "query": "math", "fields": ["topics.name"], + "type": "best_fields", } }, } @@ -1217,6 +1230,7 @@ def test_execute_learn_search_for_learning_resource_query(opensearch): "departments.department_id", "departments.name", ], + "type": "best_fields", } }, } @@ -1230,6 +1244,7 @@ def test_execute_learn_search_for_learning_resource_query(opensearch): "fields": [ "course.course_numbers.value" ], + "type": "best_fields", } }, } @@ -1245,6 +1260,7 @@ def test_execute_learn_search_for_learning_resource_query(opensearch): "runs.semester", "runs.level", ], + "type": "best_fields", } }, } @@ -1263,6 +1279,7 @@ def test_execute_learn_search_for_learning_resource_query(opensearch): "runs.instructors.last_name^5", "runs.instructors.full_name^5", ], + "type": "best_fields", } }, } @@ -1281,6 +1298,7 @@ def test_execute_learn_search_for_learning_resource_query(opensearch): "short_description.english^2", "content_feature_type", ], + "type": "best_fields", } }, "score_mode": "avg", @@ -1450,8 +1468,12 @@ def test_execute_learn_search_for_learning_resource_query(opensearch): ], ) def test_execute_learn_search_with_script_score( - mocker, opensearch, yearly_decay_percent, max_incompleteness_penalty + mocker, settings, opensearch, yearly_decay_percent, max_incompleteness_penalty ): + settings.DEFAULT_SEARCH_MODE = "phrase" + settings.DEFAULT_SEARCH_SLOP = 0 + settings.DEFAULT_SEARCH_MINIMUM_SCORE_CUTOFF = 0 + opensearch.conn.search.return_value = { "hits": {"total": {"value": 10, "relation": "eq"}} } @@ -1531,6 +1553,7 @@ def test_execute_learn_search_with_script_score( "course_feature", "video.transcript.english", ], + "type": "phrase", } }, { @@ -1542,6 +1565,7 @@ def test_execute_learn_search_with_script_score( "fields": [ "topics.name" ], + "type": "phrase", } }, } @@ -1556,6 +1580,7 @@ def test_execute_learn_search_with_script_score( "departments.department_id", "departments.name", ], + "type": "phrase", } }, } @@ -1569,6 +1594,7 @@ def test_execute_learn_search_with_script_score( "fields": [ "course.course_numbers.value" ], + "type": "phrase", } }, } @@ -1584,6 +1610,7 @@ def test_execute_learn_search_with_script_score( "runs.semester", "runs.level", ], + "type": "phrase", } }, } @@ -1602,6 +1629,7 @@ def test_execute_learn_search_with_script_score( "runs.instructors.last_name^5", "runs.instructors.full_name^5", ], + "type": "phrase", } }, } @@ -1620,6 +1648,7 @@ def test_execute_learn_search_with_script_score( "short_description.english^2", "content_feature_type", ], + "type": "phrase", } }, "score_mode": "avg", @@ -1646,6 +1675,7 @@ def test_execute_learn_search_with_script_score( "course_feature", "video.transcript.english", ], + "type": "phrase", } }, { @@ -1655,6 +1685,7 @@ def test_execute_learn_search_with_script_score( "multi_match": { "query": "math", "fields": ["topics.name"], + "type": "phrase", } }, } @@ -1669,6 +1700,7 @@ def test_execute_learn_search_with_script_score( "departments.department_id", "departments.name", ], + "type": "phrase", } }, } @@ -1682,6 +1714,7 @@ def test_execute_learn_search_with_script_score( "fields": [ "course.course_numbers.value" ], + "type": "phrase", } }, } @@ -1697,6 +1730,7 @@ def test_execute_learn_search_with_script_score( "runs.semester", "runs.level", ], + "type": "phrase", } }, } @@ -1715,6 +1749,7 @@ def test_execute_learn_search_with_script_score( "runs.instructors.last_name^5", "runs.instructors.full_name^5", ], + "type": "phrase", } }, } @@ -1733,6 +1768,7 @@ def test_execute_learn_search_with_script_score( "short_description.english^2", "content_feature_type", ], + "type": "phrase", } }, "score_mode": "avg", @@ -1898,11 +1934,15 @@ def test_execute_learn_search_with_script_score( ) -def test_execute_learn_search_with_min_score(mocker, opensearch): +def test_execute_learn_search_with_min_score(mocker, settings, opensearch): opensearch.conn.search.return_value = { "hits": {"total": {"value": 10, "relation": "eq"}} } + settings.DEFAULT_SEARCH_MAX_INCOMPLETENESS_PENALTY = 0 + settings.DEFAULT_SEARCH_STALENESS_PENALTY = 0 + settings.DEFAULT_SEARCH_MODE = "best_fields" + search_params = { "aggregations": ["offered_by"], "q": "math", @@ -1943,6 +1983,7 @@ def test_execute_learn_search_with_min_score(mocker, opensearch): "course_feature", "video.transcript.english", ], + "type": "best_fields", } }, { @@ -1954,6 +1995,7 @@ def test_execute_learn_search_with_min_score(mocker, opensearch): "fields": [ "topics.name" ], + "type": "best_fields", } }, } @@ -1968,6 +2010,7 @@ def test_execute_learn_search_with_min_score(mocker, opensearch): "departments.department_id", "departments.name", ], + "type": "best_fields", } }, } @@ -1981,6 +2024,7 @@ def test_execute_learn_search_with_min_score(mocker, opensearch): "fields": [ "course.course_numbers.value" ], + "type": "best_fields", } }, } @@ -1996,6 +2040,7 @@ def test_execute_learn_search_with_min_score(mocker, opensearch): "runs.semester", "runs.level", ], + "type": "best_fields", } }, } @@ -2014,6 +2059,7 @@ def test_execute_learn_search_with_min_score(mocker, opensearch): "runs.instructors.last_name^5", "runs.instructors.full_name^5", ], + "type": "best_fields", } }, } @@ -2032,6 +2078,7 @@ def test_execute_learn_search_with_min_score(mocker, opensearch): "short_description.english^2", "content_feature_type", ], + "type": "best_fields", } }, "score_mode": "avg", @@ -2058,6 +2105,7 @@ def test_execute_learn_search_with_min_score(mocker, opensearch): "course_feature", "video.transcript.english", ], + "type": "best_fields", } }, { @@ -2067,6 +2115,7 @@ def test_execute_learn_search_with_min_score(mocker, opensearch): "multi_match": { "query": "math", "fields": ["topics.name"], + "type": "best_fields", } }, } @@ -2081,6 +2130,7 @@ def test_execute_learn_search_with_min_score(mocker, opensearch): "departments.department_id", "departments.name", ], + "type": "best_fields", } }, } @@ -2094,6 +2144,7 @@ def test_execute_learn_search_with_min_score(mocker, opensearch): "fields": [ "course.course_numbers.value" ], + "type": "best_fields", } }, } @@ -2109,6 +2160,7 @@ def test_execute_learn_search_with_min_score(mocker, opensearch): "runs.semester", "runs.level", ], + "type": "best_fields", } }, } @@ -2127,6 +2179,7 @@ def test_execute_learn_search_with_min_score(mocker, opensearch): "runs.instructors.last_name^5", "runs.instructors.full_name^5", ], + "type": "best_fields", } }, } @@ -2145,6 +2198,7 @@ def test_execute_learn_search_with_min_score(mocker, opensearch): "short_description.english^2", "content_feature_type", ], + "type": "best_fields", } }, "score_mode": "avg", diff --git a/learning_resources_search/serializers.py b/learning_resources_search/serializers.py index 54225e29fa..fd9bd3fdcf 100644 --- a/learning_resources_search/serializers.py +++ b/learning_resources_search/serializers.py @@ -331,7 +331,6 @@ class LearningResourcesSearchRequestSerializer(SearchRequestSerializer): min_value=0, required=False, allow_null=True, - default=2.5, help_text=( "Relevance score penalty percent per year for for resources without " "upcoming runs. Only affects results if there is a search term." @@ -425,7 +424,6 @@ class LearningResourcesSearchRequestSerializer(SearchRequestSerializer): min_value=0, required=False, allow_null=True, - default=0, help_text=( "Minimum score value a text query result needs to have to be displayed" ), @@ -435,7 +433,6 @@ class LearningResourcesSearchRequestSerializer(SearchRequestSerializer): min_value=0, required=False, allow_null=True, - default=0, help_text=( "Maximum score penalty for incomplete OCW courses in percent. " "An OCW course with completeness = 0 will have this score penalty. " diff --git a/main/settings.py b/main/settings.py index 3b3adc4d56..38d644143c 100644 --- a/main/settings.py +++ b/main/settings.py @@ -766,3 +766,16 @@ def get_all_config_keys(): # Enable or disable search engine indexing MITOL_NOINDEX = get_bool("MITOL_NOINDEX", True) # noqa: FBT003 + +# Search defaults settings - adjustable throught the admin ui +DEFAULT_SEARCH_MODE = get_string(name="DEFAULT_SEARCH_MODE", default="phrase") +DEFAULT_SEARCH_SLOP = get_int(name="DEFAULT_SEARCH_SLOP", default=6) +DEFAULT_SEARCH_STALENESS_PENALTY = get_float( + name="DEFAULT_SEARCH_STALENESS_PENALTY", default=2.5 +) +DEFAULT_SEARCH_MINIMUM_SCORE_CUTOFF = get_float( + name="DEFAULT_SEARCH_MINIMUM_SCORE_CUTOFF", default=0 +) +DEFAULT_SEARCH_MAX_INCOMPLETENESS_PENALTY = get_float( + name="DEFAULT_SEARCH_MAX_INCOMPLETENESS_PENALTY", default=90 +) diff --git a/openapi/specs/v1.yaml b/openapi/specs/v1.yaml index af4d7a9bbf..ea686f47a4 100644 --- a/openapi/specs/v1.yaml +++ b/openapi/specs/v1.yaml @@ -2493,7 +2493,6 @@ paths: maximum: 100 minimum: 0 nullable: true - default: 0.0 description: Maximum score penalty for incomplete OCW courses in percent. An OCW course with completeness = 0 will have this score penalty. Partially complete courses have a linear penalty proportional to the degree of incompleteness. @@ -2506,7 +2505,6 @@ paths: maximum: 50 minimum: 0 nullable: true - default: 0.0 description: Minimum score value a text query result needs to have to be displayed - in: query name: offered_by @@ -2717,7 +2715,6 @@ paths: maximum: 10 minimum: 0 nullable: true - default: 2.5 description: Relevance score penalty percent per year for for resources without upcoming runs. Only affects results if there is a search term. tags: @@ -2976,7 +2973,6 @@ paths: maximum: 100 minimum: 0 nullable: true - default: 0.0 description: Maximum score penalty for incomplete OCW courses in percent. An OCW course with completeness = 0 will have this score penalty. Partially complete courses have a linear penalty proportional to the degree of incompleteness. @@ -2989,7 +2985,6 @@ paths: maximum: 50 minimum: 0 nullable: true - default: 0.0 description: Minimum score value a text query result needs to have to be displayed - in: query name: offered_by @@ -3200,7 +3195,6 @@ paths: maximum: 10 minimum: 0 nullable: true - default: 2.5 description: Relevance score penalty percent per year for for resources without upcoming runs. Only affects results if there is a search term. tags: @@ -3484,7 +3478,6 @@ paths: maximum: 100 minimum: 0 nullable: true - default: 0.0 description: Maximum score penalty for incomplete OCW courses in percent. An OCW course with completeness = 0 will have this score penalty. Partially complete courses have a linear penalty proportional to the degree of incompleteness. @@ -3497,7 +3490,6 @@ paths: maximum: 50 minimum: 0 nullable: true - default: 0.0 description: Minimum score value a text query result needs to have to be displayed - in: query name: offered_by @@ -3722,7 +3714,6 @@ paths: maximum: 10 minimum: 0 nullable: true - default: 2.5 description: Relevance score penalty percent per year for for resources without upcoming runs. Only affects results if there is a search term. tags: @@ -3983,7 +3974,6 @@ paths: maximum: 100 minimum: 0 nullable: true - default: 0.0 description: Maximum score penalty for incomplete OCW courses in percent. An OCW course with completeness = 0 will have this score penalty. Partially complete courses have a linear penalty proportional to the degree of incompleteness. @@ -3996,7 +3986,6 @@ paths: maximum: 50 minimum: 0 nullable: true - default: 0.0 description: Minimum score value a text query result needs to have to be displayed - in: query name: offered_by @@ -4221,7 +4210,6 @@ paths: maximum: 10 minimum: 0 nullable: true - default: 2.5 description: Relevance score penalty percent per year for for resources without upcoming runs. Only affects results if there is a search term. tags: @@ -10055,7 +10043,6 @@ components: maximum: 10 minimum: 0 nullable: true - default: 2.5 description: Relevance score penalty percent per year for for resources without upcoming runs. Only affects results if there is a search term. certification: @@ -10136,7 +10123,6 @@ components: maximum: 50 minimum: 0 nullable: true - default: 0.0 description: Minimum score value a text query result needs to have to be displayed max_incompleteness_penalty: @@ -10145,7 +10131,6 @@ components: maximum: 100 minimum: 0 nullable: true - default: 0.0 description: Maximum score penalty for incomplete OCW courses in percent. An OCW course with completeness = 0 will have this score penalty. Partially complete courses have a linear penalty proportional to the degree of incompleteness. From ac06a68cf8fc5a015b6d4e188254f1c52d830253 Mon Sep 17 00:00:00 2001 From: Doof Date: Mon, 23 Sep 2024 16:20:38 +0000 Subject: [PATCH 06/48] Release date for 0.19.2 --- RELEASE.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/RELEASE.rst b/RELEASE.rst index e8739530e8..7655b99689 100644 --- a/RELEASE.rst +++ b/RELEASE.rst @@ -1,7 +1,7 @@ Release Notes ============= -Version 0.19.2 +Version 0.19.2 (Released September 23, 2024) -------------- - Codespace opensearch service fix (#1582) From dcb1a9ac65e529df0dbc6fb7350d9c4db03ab076 Mon Sep 17 00:00:00 2001 From: Doof Date: Mon, 23 Sep 2024 16:21:13 +0000 Subject: [PATCH 07/48] Release 0.19.3 --- RELEASE.rst | 7 +++++++ main/settings.py | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/RELEASE.rst b/RELEASE.rst index 7655b99689..1f970b3ec9 100644 --- a/RELEASE.rst +++ b/RELEASE.rst @@ -1,6 +1,13 @@ Release Notes ============= +Version 0.19.3 +-------------- + +- Make search mode defaults settable env variables (#1590) +- follow/unfollow popover (#1589) +- Fix extract_openedx_data and backpopulate_mit_edx_data commands to work with course/program datafiles (#1587) + Version 0.19.2 (Released September 23, 2024) -------------- diff --git a/main/settings.py b/main/settings.py index f73c0069d9..c4e3864445 100644 --- a/main/settings.py +++ b/main/settings.py @@ -33,7 +33,7 @@ from main.settings_pluggy import * # noqa: F403 from openapi.settings_spectacular import open_spectacular_settings -VERSION = "0.19.2" +VERSION = "0.19.3" log = logging.getLogger() From 9d67642926c566b55aab9969c93e04f931e65349 Mon Sep 17 00:00:00 2001 From: Doof Date: Mon, 23 Sep 2024 19:22:18 +0000 Subject: [PATCH 08/48] Release date for 0.19.3 --- RELEASE.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/RELEASE.rst b/RELEASE.rst index 1f970b3ec9..1ae708ba43 100644 --- a/RELEASE.rst +++ b/RELEASE.rst @@ -1,7 +1,7 @@ Release Notes ============= -Version 0.19.3 +Version 0.19.3 (Released September 23, 2024) -------------- - Make search mode defaults settable env variables (#1590) From 4bde7f1742afc38406581789287d759c9a918285 Mon Sep 17 00:00:00 2001 From: Matt Bertrand Date: Tue, 24 Sep 2024 08:48:46 -0400 Subject: [PATCH 09/48] Pace and format fields for learning resources (#1588) --- .pre-commit-config.yaml | 2 +- frontends/api/src/generated/v1/api.ts | 152 +++++++++++ .../test-utils/factories/learningResources.ts | 18 ++ learning_resources/constants.py | 14 + learning_resources/etl/constants.py | 2 +- learning_resources/etl/micromasters.py | 78 ++++-- learning_resources/etl/micromasters_test.py | 9 + learning_resources/etl/mitxonline.py | 52 +++- learning_resources/etl/mitxonline_test.py | 18 +- learning_resources/etl/ocw.py | 6 + learning_resources/etl/ocw_test.py | 6 + learning_resources/etl/oll.py | 6 + learning_resources/etl/oll_test.py | 8 + learning_resources/etl/openedx.py | 50 +++- learning_resources/etl/openedx_test.py | 16 ++ learning_resources/etl/prolearn.py | 10 +- learning_resources/etl/prolearn_test.py | 12 + learning_resources/etl/sloan.py | 54 +++- learning_resources/etl/sloan_test.py | 57 ++++ learning_resources/etl/xpro.py | 10 + learning_resources/etl/xpro_test.py | 14 + .../0068_learningresource_format_pace.py | 75 +++++ learning_resources/models.py | 40 +++ learning_resources/serializers.py | 36 +++ learning_resources/serializers_test.py | 9 + learning_resources_search/constants.py | 28 ++ main/models.py | 2 +- openapi/specs/v1.yaml | 256 ++++++++++++++++++ 28 files changed, 993 insertions(+), 47 deletions(-) create mode 100644 learning_resources/migrations/0068_learningresource_format_pace.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 17d9ff63e6..0a30546482 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -74,7 +74,7 @@ repos: - ".*/generated/" additional_dependencies: ["gibberish-detector"] - repo: https://github.com/astral-sh/ruff-pre-commit - rev: "v0.6.5" + rev: "v0.6.6" hooks: - id: ruff-format - id: ruff diff --git a/frontends/api/src/generated/v1/api.ts b/frontends/api/src/generated/v1/api.ts index 983db187e9..ff24f6357f 100644 --- a/frontends/api/src/generated/v1/api.ts +++ b/frontends/api/src/generated/v1/api.ts @@ -686,6 +686,18 @@ export interface CourseResource { * @memberof CourseResource */ resource_category: string + /** + * + * @type {Array} + * @memberof CourseResource + */ + format: Array + /** + * + * @type {Array} + * @memberof CourseResource + */ + pace: Array /** * * @type {CourseResourceResourceTypeEnum} @@ -844,6 +856,62 @@ export const CourseResourceDeliveryInnerCodeEnum = { export type CourseResourceDeliveryInnerCodeEnum = (typeof CourseResourceDeliveryInnerCodeEnum)[keyof typeof CourseResourceDeliveryInnerCodeEnum] +/** + * + * @export + * @interface CourseResourceFormatInner + */ +export interface CourseResourceFormatInner { + /** + * + * @type {string} + * @memberof CourseResourceFormatInner + */ + code: CourseResourceFormatInnerCodeEnum + /** + * + * @type {string} + * @memberof CourseResourceFormatInner + */ + name: string +} + +export const CourseResourceFormatInnerCodeEnum = { + Synchronous: "synchronous", + Asynchronous: "asynchronous", +} as const + +export type CourseResourceFormatInnerCodeEnum = + (typeof CourseResourceFormatInnerCodeEnum)[keyof typeof CourseResourceFormatInnerCodeEnum] + +/** + * + * @export + * @interface CourseResourcePaceInner + */ +export interface CourseResourcePaceInner { + /** + * + * @type {string} + * @memberof CourseResourcePaceInner + */ + code: CourseResourcePaceInnerCodeEnum + /** + * + * @type {string} + * @memberof CourseResourcePaceInner + */ + name: string +} + +export const CourseResourcePaceInnerCodeEnum = { + SelfPaced: "self_paced", + InstructorPaced: "instructor_paced", +} as const + +export type CourseResourcePaceInnerCodeEnum = + (typeof CourseResourcePaceInnerCodeEnum)[keyof typeof CourseResourcePaceInnerCodeEnum] + /** * Serializer for course resources * @export @@ -1376,6 +1444,18 @@ export interface LearningPathResource { * @memberof LearningPathResource */ resource_category: string + /** + * + * @type {Array} + * @memberof LearningPathResource + */ + format: Array + /** + * + * @type {Array} + * @memberof LearningPathResource + */ + pace: Array /** * * @type {LearningPathResourceResourceTypeEnum} @@ -2100,6 +2180,18 @@ export interface LearningResourceRun { * @memberof LearningResourceRun */ delivery: Array + /** + * + * @type {Array} + * @memberof LearningResourceRun + */ + format: Array + /** + * + * @type {Array} + * @memberof LearningResourceRun + */ + pace: Array /** * * @type {string} @@ -4049,6 +4141,18 @@ export interface PodcastEpisodeResource { * @memberof PodcastEpisodeResource */ resource_category: string + /** + * + * @type {Array} + * @memberof PodcastEpisodeResource + */ + format: Array + /** + * + * @type {Array} + * @memberof PodcastEpisodeResource + */ + pace: Array /** * * @type {PodcastEpisodeResourceResourceTypeEnum} @@ -4395,6 +4499,18 @@ export interface PodcastResource { * @memberof PodcastResource */ resource_category: string + /** + * + * @type {Array} + * @memberof PodcastResource + */ + format: Array + /** + * + * @type {Array} + * @memberof PodcastResource + */ + pace: Array /** * * @type {PodcastResourceResourceTypeEnum} @@ -4961,6 +5077,18 @@ export interface ProgramResource { * @memberof ProgramResource */ resource_category: string + /** + * + * @type {Array} + * @memberof ProgramResource + */ + format: Array + /** + * + * @type {Array} + * @memberof ProgramResource + */ + pace: Array /** * * @type {ProgramResourceResourceTypeEnum} @@ -5786,6 +5914,18 @@ export interface VideoPlaylistResource { * @memberof VideoPlaylistResource */ resource_category: string + /** + * + * @type {Array} + * @memberof VideoPlaylistResource + */ + format: Array + /** + * + * @type {Array} + * @memberof VideoPlaylistResource + */ + pace: Array /** * * @type {VideoPlaylistResourceResourceTypeEnum} @@ -6120,6 +6260,18 @@ export interface VideoResource { * @memberof VideoResource */ resource_category: string + /** + * + * @type {Array} + * @memberof VideoResource + */ + format: Array + /** + * + * @type {Array} + * @memberof VideoResource + */ + pace: Array /** * * @type {VideoResourceResourceTypeEnum} diff --git a/frontends/api/src/test-utils/factories/learningResources.ts b/frontends/api/src/test-utils/factories/learningResources.ts index 8c11ddf195..f37c0092fb 100644 --- a/frontends/api/src/test-utils/factories/learningResources.ts +++ b/frontends/api/src/test-utils/factories/learningResources.ts @@ -30,6 +30,8 @@ import type { import { AvailabilityEnum, DeliveryEnum, + CourseResourcePaceInnerCodeEnum, + CourseResourceFormatInnerCodeEnum, ResourceTypeEnum, LearningResourceRunLevelInnerCodeEnum, PlatformEnum, @@ -186,6 +188,22 @@ const learningResourceRun: Factory = (overrides = {}) => { name: uniqueEnforcerWords.enforce(() => faker.lorem.words()), }, ], + pace: [ + { + code: faker.helpers.arrayElement( + Object.values(CourseResourcePaceInnerCodeEnum), + ), + name: uniqueEnforcerWords.enforce(() => faker.lorem.words()), + }, + ], + format: [ + { + code: faker.helpers.arrayElement( + Object.values(CourseResourceFormatInnerCodeEnum), + ), + name: uniqueEnforcerWords.enforce(() => faker.lorem.words()), + }, + ], level: [ { code: faker.helpers.arrayElement( diff --git a/learning_resources/constants.py b/learning_resources/constants.py index cdc609d824..41e84d6edb 100644 --- a/learning_resources/constants.py +++ b/learning_resources/constants.py @@ -287,3 +287,17 @@ class CertificationType(ExtendedEnum): professional = "Professional Certificate" completion = "Certificate of Completion" none = "No Certificate" + + +class Pace(ExtendedEnum): + """Enum for resource pace types""" + + self_paced = "Self-paced" + instructor_paced = "Instructor-paced" + + +class Format(ExtendedEnum): + """Enum for resource format types""" + + synchronous = "Synchronous" + asynchronous = "Asynchronous" diff --git a/learning_resources/etl/constants.py b/learning_resources/etl/constants.py index 319f85f245..fd1e7fcc6a 100644 --- a/learning_resources/etl/constants.py +++ b/learning_resources/etl/constants.py @@ -46,7 +46,7 @@ ) -class ETLSource(Enum): +class ETLSource(ExtendedEnum): """Enum of ETL sources""" micromasters = "micromasters" diff --git a/learning_resources/etl/micromasters.py b/learning_resources/etl/micromasters.py index b4bec1232e..6bebb30d35 100644 --- a/learning_resources/etl/micromasters.py +++ b/learning_resources/etl/micromasters.py @@ -8,12 +8,13 @@ from learning_resources.constants import ( Availability, CertificationType, + Format, LearningResourceType, OfferedBy, PlatformType, ) from learning_resources.etl.constants import COMMON_HEADERS, ETLSource -from learning_resources.models import LearningResource +from learning_resources.models import LearningResource, default_pace OFFERED_BY = {"code": OfferedBy.mitx.name} READABLE_ID_PREFIX = "micromasters-program-" @@ -46,6 +47,27 @@ def _is_published(course_id: str) -> bool: return False +def _get_course_pace(course_id: str) -> list[str]: + """ + Determine the pace of the course by id + + Args: + course_id (str): the course id + + Returns: + list[str]: the pace of the course as a list of strings + + """ + existing_course = LearningResource.objects.filter( + readable_id=course_id, + resource_type=LearningResourceType.course.name, + published=True, + ).first() + if existing_course: + return existing_course.pace + return default_pace() + + def _transform_image(micromasters_data: dict) -> dict: """ Transform an image into our normalized data structure @@ -65,7 +87,36 @@ def transform(programs_data): programs = [] for program in programs_data: url = program.get("programpage_url") + # need positioning of courses by course_id for course data + courses = [ + { + "readable_id": course["edx_key"], + "platform": PlatformType.edx.name, + "offered_by": OFFERED_BY, + "published": _is_published(course["edx_key"]), + "pace": _get_course_pace(course["edx_key"]), + "runs": [ + { + "run_id": run["edx_course_key"], + } + for run in course["course_runs"] + if run.get("edx_course_key", None) + ], + } + for course in sorted( + program["courses"], + key=lambda course: course["position_in_program"], + ) + ] if url and DEDP not in url: + pace = sorted( + { + pace + for course in courses + for pace in course["pace"] + if course["published"] + } + ) programs.append( { "readable_id": f"{READABLE_ID_PREFIX}{program['id']}", @@ -91,30 +142,15 @@ def transform(programs_data): "end_date": program["end_date"], "enrollment_start": program["enrollment_start"], "availability": Availability.dated.name, + "pace": pace, + "format": [Format.asynchronous.name], } ], "topics": program["topics"], "availability": Availability.dated.name, - # only need positioning of courses by course_id for course data - "courses": [ - { - "readable_id": course["edx_key"], - "platform": PlatformType.edx.name, - "offered_by": OFFERED_BY, - "published": _is_published(course["edx_key"]), - "runs": [ - { - "run_id": run["edx_course_key"], - } - for run in course["course_runs"] - if run.get("edx_course_key", None) - ], - } - for course in sorted( - program["courses"], - key=lambda course: course["position_in_program"], - ) - ], + "pace": pace, + "format": [Format.asynchronous.name], + "courses": courses, } ) return programs diff --git a/learning_resources/etl/micromasters_test.py b/learning_resources/etl/micromasters_test.py index ede2c8df77..343d61260c 100644 --- a/learning_resources/etl/micromasters_test.py +++ b/learning_resources/etl/micromasters_test.py @@ -6,7 +6,9 @@ from learning_resources.constants import ( Availability, CertificationType, + Format, LearningResourceType, + Pace, PlatformType, ) from learning_resources.etl import micromasters @@ -111,6 +113,7 @@ def test_micromasters_transform(mock_micromasters_data, missing_url): readable_id="1", resource_type=LearningResourceType.course.name, etl_source=ETLSource.mit_edx.name, + pace=[Pace.instructor_paced.name], ) if missing_url: mock_micromasters_data[0]["programpage_url"] = None @@ -129,6 +132,8 @@ def test_micromasters_transform(mock_micromasters_data, missing_url): "certification": True, "certification_type": CertificationType.micromasters.name, "availability": Availability.dated.name, + "pace": [Pace.instructor_paced.name], + "format": [Format.asynchronous.name], "courses": [ { "readable_id": "1", @@ -140,6 +145,7 @@ def test_micromasters_transform(mock_micromasters_data, missing_url): "run_id": "course_key_1", } ], + "pace": [Pace.instructor_paced.name], }, { "readable_id": "2", @@ -147,6 +153,7 @@ def test_micromasters_transform(mock_micromasters_data, missing_url): "offered_by": micromasters.OFFERED_BY, "published": False, "runs": [], + "pace": [Pace.self_paced.name], }, ], "runs": [ @@ -162,6 +169,8 @@ def test_micromasters_transform(mock_micromasters_data, missing_url): "end_date": None, "enrollment_start": "2019-09-29T20:13:26.367297Z", "availability": Availability.dated.name, + "pace": [Pace.instructor_paced.name], + "format": [Format.asynchronous.name], } ], "topics": [{"name": "program"}, {"name": "first"}], diff --git a/learning_resources/etl/mitxonline.py b/learning_resources/etl/mitxonline.py index 7495bd9f12..296174d960 100644 --- a/learning_resources/etl/mitxonline.py +++ b/learning_resources/etl/mitxonline.py @@ -12,8 +12,10 @@ from learning_resources.constants import ( CertificationType, + Format, LearningResourceType, OfferedBy, + Pace, PlatformType, RunStatus, ) @@ -237,6 +239,12 @@ def _transform_run(course_run: dict, course: dict) -> dict: if parse_page_attribute(course, "page_url") else RunStatus.archived.value, "availability": course.get("availability"), + "format": [Format.asynchronous.name], + "pace": [ + Pace.self_paced.name + if course_run.get("is_self_paced", False) + else Pace.instructor_paced.name + ], } @@ -283,6 +291,8 @@ def _transform_course(course): "url": parse_page_attribute(course, "page_url", is_url=True), "description": clean_data(parse_page_attribute(course, "description")), "availability": course.get("availability"), + "format": [Format.asynchronous.name], + "pace": sorted({pace for run in runs for pace in run["pace"]}), } @@ -320,12 +330,30 @@ def _fetch_courses_by_ids(course_ids): return [] -def transform_programs(programs): - """Transform the MITX Online catalog data""" - # normalize the MITx Online data +def transform_programs(programs: list[dict]) -> list[dict]: + """ + Transform the MITX Online catalog data - return [ - { + Args: + programs (list of dict): the MITX Online programs data + + Returns: + list of dict: the transformed programs data + + """ + # normalize the MITx Online data + for program in programs: + courses = transform_courses( + [ + course + for course in _fetch_courses_by_ids(program["courses"]) + if not re.search(EXCLUDE_REGEX, course["title"], re.IGNORECASE) + ] + ) + pace = sorted( + {course_pace for course in courses for course_pace in course["pace"]} + ) + yield { "readable_id": program["readable_id"], "title": program["title"], "offered_by": OFFERED_BY, @@ -346,6 +374,8 @@ def transform_programs(programs): "published": bool( parse_page_attribute(program, "page_url") ), # a program is only considered published if it has a page url + "format": [Format.asynchronous.name], + "pace": pace, "runs": [ { "run_id": program["readable_id"], @@ -371,15 +401,9 @@ def transform_programs(programs): if parse_page_attribute(program, "page_url") else RunStatus.archived.value, "availability": program.get("availability"), + "format": [Format.asynchronous.name], + "pace": pace, } ], - "courses": transform_courses( - [ - course - for course in _fetch_courses_by_ids(program["courses"]) - if not re.search(EXCLUDE_REGEX, course["title"], re.IGNORECASE) - ] - ), + "courses": courses, } - for program in programs - ] diff --git a/learning_resources/etl/mitxonline_test.py b/learning_resources/etl/mitxonline_test.py index 637cc81209..a7364d393c 100644 --- a/learning_resources/etl/mitxonline_test.py +++ b/learning_resources/etl/mitxonline_test.py @@ -11,7 +11,9 @@ from learning_resources.constants import ( CertificationType, + Format, LearningResourceType, + Pace, PlatformType, RunStatus, ) @@ -150,6 +152,8 @@ def test_mitxonline_transform_programs( "url": parse_page_attribute(program_data, "page_url", is_url=True), "availability": program_data["availability"], "topics": transform_topics(program_data["topics"], OFFERED_BY["code"]), + "format": [Format.asynchronous.name], + "pace": [Pace.self_paced.name], "runs": [ { "run_id": program_data["readable_id"], @@ -171,6 +175,8 @@ def test_mitxonline_transform_programs( if parse_page_attribute(program_data, "page_url") else RunStatus.archived.value, "availability": program_data["availability"], + "format": [Format.asynchronous.name], + "pace": [Pace.self_paced.name], } ], "courses": [ @@ -205,6 +211,8 @@ def test_mitxonline_transform_programs( "certification_type": CertificationType.completion.name, "url": parse_page_attribute(course_data, "page_url", is_url=True), "availability": course_data["availability"], + "format": [Format.asynchronous.name], + "pace": [Pace.self_paced.name], "topics": transform_topics( course_data["topics"], OFFERED_BY["code"] ), @@ -250,6 +258,8 @@ def test_mitxonline_transform_programs( if parse_page_attribute(course_data, "page_url") else RunStatus.archived.value, "availability": course_data["availability"], + "format": [Format.asynchronous.name], + "pace": [Pace.self_paced.name], } for course_run_data in course_data["courseruns"] ], @@ -379,6 +389,8 @@ def test_mitxonline_transform_courses(settings, mock_mitxonline_courses_data): if parse_page_attribute(course_data, "page_url") else RunStatus.archived.value, "availability": course_data["availability"], + "format": [Format.asynchronous.name], + "pace": [Pace.self_paced.name], } for course_run_data in course_data["courseruns"] ], @@ -394,6 +406,8 @@ def test_mitxonline_transform_courses(settings, mock_mitxonline_courses_data): ] }, "availability": course_data["availability"], + "format": [Format.asynchronous.name], + "pace": [Pace.self_paced.name], } for course_data in mock_mitxonline_courses_data["results"] if "PROCTORED EXAM" not in course_data["title"] @@ -463,7 +477,9 @@ def test_program_run_start_date_value( # noqa: PLR0913 mock_mitxonline_programs_data["results"][0]["start_date"] = start_dt mock_mitxonline_programs_data["results"][0]["enrollment_start"] = enrollment_dt - transformed_programs = transform_programs(mock_mitxonline_programs_data["results"]) + transformed_programs = list( + transform_programs(mock_mitxonline_programs_data["results"]) + ) assert transformed_programs[0]["runs"][0]["start_date"] == _parse_datetime( expected_dt diff --git a/learning_resources/etl/ocw.py b/learning_resources/etl/ocw.py index f11310b8e2..d776f63c82 100644 --- a/learning_resources/etl/ocw.py +++ b/learning_resources/etl/ocw.py @@ -20,9 +20,11 @@ CONTENT_TYPE_VIDEO, VALID_TEXT_FILE_TYPES, Availability, + Format, LearningResourceDelivery, LearningResourceType, OfferedBy, + Pace, PlatformType, RunStatus, ) @@ -285,6 +287,8 @@ def transform_run(course_data: dict) -> dict: "url": course_data["url"], "availability": Availability.anytime.name, "delivery": parse_delivery(course_data), + "format": [Format.asynchronous.name], + "pace": [Pace.self_paced.name], } @@ -368,6 +372,8 @@ def transform_course(course_data: dict) -> dict: "availability": Availability.anytime.name, "delivery": parse_delivery(course_data), "license_cc": True, + "format": [Format.asynchronous.name], + "pace": [Pace.self_paced.name], } diff --git a/learning_resources/etl/ocw_test.py b/learning_resources/etl/ocw_test.py index 5d2ba82828..d54b562ce7 100644 --- a/learning_resources/etl/ocw_test.py +++ b/learning_resources/etl/ocw_test.py @@ -12,7 +12,9 @@ from learning_resources.constants import ( DEPARTMENTS, Availability, + Format, LearningResourceDelivery, + Pace, ) from learning_resources.etl.constants import CourseNumberType, ETLSource from learning_resources.etl.ocw import ( @@ -248,6 +250,10 @@ def test_transform_course( # noqa: PLR0913 assert transformed_json["runs"][0]["delivery"] == expected_delivery assert transformed_json["runs"][0]["availability"] == Availability.anytime.name assert transformed_json["availability"] == Availability.anytime.name + assert transformed_json["runs"][0]["pace"] == [Pace.self_paced.name] + assert transformed_json["runs"][0]["format"] == [Format.asynchronous.name] + assert transformed_json["pace"] == [Pace.self_paced.name] + assert transformed_json["format"] == [Format.asynchronous.name] assert transformed_json["description"] == clean_data( course_json["course_description_html"] ) diff --git a/learning_resources/etl/oll.py b/learning_resources/etl/oll.py index 5c720cbafe..96bd8436b3 100644 --- a/learning_resources/etl/oll.py +++ b/learning_resources/etl/oll.py @@ -13,7 +13,9 @@ from learning_resources.constants import ( Availability, + Format, OfferedBy, + Pace, PlatformType, RunStatus, ) @@ -143,6 +145,8 @@ def transform_run(course_data: dict) -> list[dict]: ], "status": RunStatus.archived.value, "availability": Availability.anytime.name, + "pace": [Pace.self_paced.name], + "format": [Format.asynchronous.name], } ] @@ -183,6 +187,8 @@ def transform_course(course_data: dict) -> dict: "prices": [Decimal(0.00)], "etl_source": ETLSource.oll.name, "availability": Availability.anytime.name, + "pace": [Pace.self_paced.name], + "format": [Format.asynchronous.name], } diff --git a/learning_resources/etl/oll_test.py b/learning_resources/etl/oll_test.py index f0eebf8ea6..91e9e05c7d 100644 --- a/learning_resources/etl/oll_test.py +++ b/learning_resources/etl/oll_test.py @@ -73,6 +73,8 @@ def test_oll_transform(mocker, oll_course_data): "year": 2022, "status": "Archived", "availability": "anytime", + "pace": ["self_paced"], + "format": ["asynchronous"], } ], "image": { @@ -82,6 +84,8 @@ def test_oll_transform(mocker, oll_course_data): "prices": [0.00], "etl_source": "oll", "availability": "anytime", + "pace": ["self_paced"], + "format": ["asynchronous"], } assert results[2] == { "title": "Competency-Based Education", @@ -125,6 +129,8 @@ def test_oll_transform(mocker, oll_course_data): "year": 2019, "status": "Archived", "availability": "anytime", + "pace": ["self_paced"], + "format": ["asynchronous"], } ], "image": { @@ -134,4 +140,6 @@ def test_oll_transform(mocker, oll_course_data): "prices": [0.00], "etl_source": "oll", "availability": "anytime", + "pace": ["self_paced"], + "format": ["asynchronous"], } diff --git a/learning_resources/etl/openedx.py b/learning_resources/etl/openedx.py index c7cc98b532..18a1ce118c 100644 --- a/learning_resources/etl/openedx.py +++ b/learning_resources/etl/openedx.py @@ -18,7 +18,9 @@ from learning_resources.constants import ( Availability, CertificationType, + Format, LearningResourceType, + Pace, PlatformType, RunStatus, ) @@ -290,7 +292,7 @@ def _parse_course_dates(program, date_field): [ run[date_field] for run in course["course_runs"] - if run["status"] == "published" and run[date_field] + if _get_run_published(run) and run[date_field] ] ) return dates @@ -312,7 +314,7 @@ def _get_course_price(course): for run in sorted( course["course_runs"], key=lambda x: x["start"], reverse=False ): - if run["status"] == "published": + if _get_run_published(run): return min( [ Decimal(seat["price"]) @@ -356,6 +358,8 @@ def _transform_course_run(config, course_run, course_last_modified, marketing_ur "image": _transform_course_image(course_run.get("image")), "status": course_run.get("availability"), "availability": _get_run_availability(course_run).name, + "format": [Format.asynchronous.name], + "pace": [course_run.get("pacing_type") or Pace.self_paced.name], "url": marketing_url or "{}{}/course/".format(config.alt_url, course_run.get("key")), "prices": sorted( @@ -401,6 +405,25 @@ def _parse_program_instructors_topics(program): ) +def _parse_course_pace(runs: list[dict]) -> list[str]: + """ + Parse the pace of a course based on its runs + + Args: + runs (list of dict): the runs data + + Returns: + str: the pace of the course or programe + """ + pace = sorted( + {run["pacing_type"] for run in runs if run and _get_run_published(run)} + ) + if len(pace) == 0: + # Archived courses are considered self-paced + pace = [Pace.self_paced.name] + return pace + + def _transform_program_course(config: OpenEdxConfiguration, course: dict) -> dict: """ Transform a program's course dict to a normalized data structure @@ -419,6 +442,7 @@ def _transform_program_course(config: OpenEdxConfiguration, course: dict) -> dic "platform": config.platform, "resource_type": LearningResourceType.course.name, "offered_by": {"code": config.offered_by}, + "pace": _parse_course_pace(course.get("course_runs", [])), } @@ -458,6 +482,14 @@ def _transform_program_run( "prices": [_sum_course_prices(program)], "instructors": program.pop("instructors", []), "availability": _get_program_availability(program), + "format": [Format.asynchronous.name], + "pace": sorted( + { + pace + for course in program.get("courses", []) + for pace in _parse_course_pace(course.get("course_runs", [])) + } + ), } @@ -507,6 +539,8 @@ def _transform_course(config: OpenEdxConfiguration, course: dict) -> dict: if has_certification else CertificationType.none.name, "availability": _get_course_availability(course), + "format": [Format.asynchronous.name], + "pace": _parse_course_pace(course.get("course_runs", [])), } @@ -526,6 +560,11 @@ def _transform_program(config: OpenEdxConfiguration, program: dict) -> dict: image = _transform_program_image(program) instructors, topics = _parse_program_instructors_topics(program) program["instructors"] = instructors + courses = [ + _transform_program_course(config, course) + for course in program.get("courses", []) + ] + paces = sorted({pace for course in courses for pace in course["pace"]}) runs = [_transform_program_run(program, last_modified, image)] has_certification = parse_certification(config.offered_by, runs) return { @@ -549,10 +588,9 @@ def _transform_program(config: OpenEdxConfiguration, program: dict) -> dict: if has_certification else CertificationType.none.name, "availability": runs[0]["availability"], - "courses": [ - _transform_program_course(config, course) - for course in program.get("courses", []) - ], + "format": [Format.asynchronous.name], + "pace": paces, + "courses": courses, } diff --git a/learning_resources/etl/openedx_test.py b/learning_resources/etl/openedx_test.py index 3503cc15c4..3ae662fbe7 100644 --- a/learning_resources/etl/openedx_test.py +++ b/learning_resources/etl/openedx_test.py @@ -10,8 +10,10 @@ from learning_resources.constants import ( Availability, CertificationType, + Format, LearningResourceType, OfferedBy, + Pace, PlatformType, RunStatus, ) @@ -212,6 +214,13 @@ def test_transform_course( # noqa: PLR0913 "last_modified": any_instance_of(datetime), "topics": [{"name": "Data Analysis & Statistics"}], "url": "http://localhost/fake-alt-url/this_course", + "format": [Format.asynchronous.name], + "pace": [Pace.instructor_paced.name] + if has_runs + and not is_run_deleted + and is_run_published + and is_run_enrollable + else [Pace.self_paced.name], "published": is_run_published and is_run_enrollable and not is_run_deleted @@ -249,6 +258,8 @@ def test_transform_course( # noqa: PLR0913 "year": 2019, "published": is_run_enrollable and is_run_published, "availability": Availability.dated.name, + "format": [Format.asynchronous.name], + "pace": [Pace.instructor_paced.name], } ] ), @@ -422,6 +433,8 @@ def test_transform_program( "certification": False, "certification_type": CertificationType.none.name, "availability": Availability.anytime.name, + "format": [Format.asynchronous.name], + "pace": [Pace.self_paced.name], "runs": ( [ { @@ -450,6 +463,8 @@ def test_transform_program( "url": extracted[0]["marketing_url"], "published": True, "availability": Availability.anytime.name, + "format": [Format.asynchronous.name], + "pace": [Pace.self_paced.name], } ] ), @@ -460,6 +475,7 @@ def test_transform_program( "platform": openedx_program_config.platform, "readable_id": f"MITx+6.002.{i}x", "resource_type": LearningResourceType.course.name, + "pace": [Pace.self_paced.name], } for i in range(1, 4) ], diff --git a/learning_resources/etl/prolearn.py b/learning_resources/etl/prolearn.py index de00d3b9d3..022aa6cc62 100644 --- a/learning_resources/etl/prolearn.py +++ b/learning_resources/etl/prolearn.py @@ -9,7 +9,7 @@ import requests from django.conf import settings -from learning_resources.constants import Availability, CertificationType +from learning_resources.constants import Availability, CertificationType, Format, Pace from learning_resources.etl.constants import ETLSource from learning_resources.etl.utils import transform_delivery, transform_topics from learning_resources.models import LearningResourceOfferor, LearningResourcePlatform @@ -281,10 +281,14 @@ def transform_programs(programs: list[dict]) -> list[dict]: } ], "unique_field": UNIQUE_FIELD, + "pace": [Pace.instructor_paced.name], + "format": [Format.asynchronous.name], } for course_id in sorted(program["field_related_courses_programs"]) ], "unique_field": UNIQUE_FIELD, + "pace": [Pace.instructor_paced.name], + "format": [Format.asynchronous.name], } unique_program = unique_programs.setdefault( transformed_program["url"], transformed_program @@ -321,6 +325,8 @@ def _transform_runs(resource: dict) -> list[dict]: "url": parse_url(resource), "delivery": transform_delivery(resource["format_name"]), "availability": Availability.dated.name, + "pace": [Pace.instructor_paced.name], + "format": [Format.asynchronous.name], } ) return runs @@ -361,6 +367,8 @@ def _transform_course( "runs": runs, "unique_field": UNIQUE_FIELD, "availability": Availability.dated.name, + "pace": [Pace.instructor_paced.name], + "format": [Format.asynchronous.name], } return None diff --git a/learning_resources/etl/prolearn_test.py b/learning_resources/etl/prolearn_test.py index 00eb8fc267..9390b73a48 100644 --- a/learning_resources/etl/prolearn_test.py +++ b/learning_resources/etl/prolearn_test.py @@ -10,8 +10,10 @@ from learning_resources.constants import ( Availability, CertificationType, + Format, LearningResourceDelivery, OfferedBy, + Pace, PlatformType, ) from learning_resources.etl.constants import ETLSource @@ -177,6 +179,8 @@ def test_prolearn_transform_programs(mock_csail_programs_data): ), "availability": Availability.dated.name, "delivery": transform_delivery(program["format_name"]), + "pace": [Pace.instructor_paced.name], + "format": [Format.asynchronous.name], } for (start_val, end_val) in zip( program["start_value"], program["end_value"] @@ -201,10 +205,14 @@ def test_prolearn_transform_programs(mock_csail_programs_data): } ], "unique_field": UNIQUE_FIELD, + "pace": [Pace.instructor_paced.name], + "format": [Format.asynchronous.name], } for course_id in sorted(program["field_related_courses_programs"]) ], "unique_field": UNIQUE_FIELD, + "pace": [Pace.instructor_paced.name], + "format": [Format.asynchronous.name], } for program in extracted_data[1:] ] @@ -254,6 +262,8 @@ def test_prolearn_transform_courses(mock_mitpe_courses_data): ), "availability": Availability.dated.name, "delivery": transform_delivery(course["format_name"]), + "pace": [Pace.instructor_paced.name], + "format": [Format.asynchronous.name], } for (start_val, end_val) in zip( course["start_value"], course["end_value"] @@ -261,6 +271,8 @@ def test_prolearn_transform_courses(mock_mitpe_courses_data): ], "course": {"course_numbers": []}, "unique_field": UNIQUE_FIELD, + "pace": [Pace.instructor_paced.name], + "format": [Format.asynchronous.name], } for course in extracted_data[2:] ] diff --git a/learning_resources/etl/sloan.py b/learning_resources/etl/sloan.py index 3697c5690d..a58b4e311e 100644 --- a/learning_resources/etl/sloan.py +++ b/learning_resources/etl/sloan.py @@ -12,7 +12,9 @@ from learning_resources.constants import ( Availability, CertificationType, + Format, OfferedBy, + Pace, PlatformType, RunStatus, ) @@ -21,6 +23,7 @@ transform_delivery, transform_topics, ) +from learning_resources.models import default_format log = logging.getLogger(__name__) @@ -124,6 +127,49 @@ def parse_availability(run_data: dict) -> str: return Availability.dated.name +def parse_pace(run_data: dict) -> str: + """ + Parse pace from run data + + Args: + run_data (list): the run data + + Returns: + str: the pace + """ + if run_data and ( + run_data.get("Delivery") == "Online" + and run_data.get("Format") == "Asynchronous (On-Demand)" + ): + return Pace.self_paced.name + return Pace.instructor_paced.name + + +def parse_format(run_data: dict) -> str: + """ + Parse format from run data + + Args: + run_data (list): the run data + + Returns: + str: the format code + """ + if run_data: + delivery = run_data.get("Delivery") + if delivery == "In Person": + return [Format.synchronous.name] + elif delivery == "Blended": + return [Format.synchronous.name, Format.asynchronous.name] + else: + return ( + [Format.asynchronous.name] + if "Asynchronous" in (run_data.get("Format") or "") + else [Format.synchronous.name] + ) + return default_format() + + def extract(): """ Extract Sloan Executive Education data @@ -184,6 +230,8 @@ def transform_run(run_data, course_data): "published": True, "prices": [run_data["Price"]], "instructors": [{"full_name": name.strip()} for name in faculty_names], + "pace": [parse_pace(run_data)], + "format": parse_format(run_data), } @@ -205,6 +253,8 @@ def transform_course(course_data: dict, runs_data: dict) -> dict: format_delivery = list( {transform_delivery(run["Delivery"])[0] for run in course_runs_data} ) + runs = [transform_run(run, course_data) for run in course_runs_data] + transformed_course = { "readable_id": course_data["Course_Id"], "title": course_data["Title"], @@ -223,8 +273,10 @@ def transform_course(course_data: dict, runs_data: dict) -> dict: "course": { "course_numbers": [], }, - "runs": [transform_run(run, course_data) for run in course_runs_data], + "runs": runs, "continuing_ed_credits": course_runs_data[0]["Continuing_Ed_Credits"], + "pace": sorted({pace for run in runs for pace in run["pace"]}), + "format": sorted({run_format for run in runs for run_format in run["format"]}), } return transformed_course if transformed_course.get("url") else None diff --git a/learning_resources/etl/sloan_test.py b/learning_resources/etl/sloan_test.py index 9cb6a6cfa6..85c472d15e 100644 --- a/learning_resources/etl/sloan_test.py +++ b/learning_resources/etl/sloan_test.py @@ -7,13 +7,17 @@ from learning_resources.constants import ( Availability, + Format, + Pace, RunStatus, ) from learning_resources.etl.sloan import ( extract, parse_availability, parse_datetime, + parse_format, parse_image, + parse_pace, transform_course, transform_delivery, transform_run, @@ -124,6 +128,8 @@ def test_transform_run( "published": True, "prices": [run_data["Price"]], "instructors": [{"full_name": name.strip()} for name in faculty_names], + "pace": [Pace.instructor_paced.name], + "format": [Format.synchronous.name], } @@ -147,6 +153,10 @@ def test_transform_course(mock_sloan_courses_data, mock_sloan_runs_data): assert transformed["runs"][0]["availability"] == parse_availability( course_runs_data[0] ) + assert transformed["pace"] == [Pace.instructor_paced.name] + assert transformed["format"] == [Format.asynchronous.name, Format.synchronous.name] + assert transformed["runs"][0]["pace"] == [Pace.instructor_paced.name] + assert transformed["runs"][0]["format"] == [Format.asynchronous.name] assert transformed["image"] == parse_image(course_data) assert ( transformed["continuing_ed_credits"] @@ -171,6 +181,8 @@ def test_transform_course(mock_sloan_courses_data, mock_sloan_runs_data): "course", "runs", "continuing_ed_credits", + "pace", + "format", ] ) @@ -223,3 +235,48 @@ def test_enabled_flag(mock_sloan_api_setting, settings): """Extract should return empty lists if the SEE_API_ENABLED flag is False""" settings.SEE_API_ENABLED = False assert extract() == ([], []) + + +@pytest.mark.parametrize( + ("delivery", "run_format", "pace"), + [ + ("Online", "Synchronous", Pace.instructor_paced.name), + ("Online", "Asynchronous (On-Demand)", Pace.self_paced.name), + ("Online", "Asynchronous (Date based)", Pace.instructor_paced.name), + ("In Person", "Asynchronous (On-Demand)", Pace.instructor_paced.name), + ], +) +def test_parse_pace(delivery, run_format, pace): + """Test that the pace is parsed correctly""" + run_data = { + "Format": run_format, + "Delivery": delivery, + } + assert parse_pace(run_data) == pace + assert parse_pace(None) == Pace.instructor_paced.name + + +@pytest.mark.parametrize( + ("delivery", "run_format", "expected_format"), + [ + ("In Person", "Asynchronous (On-Demand)", [Format.synchronous.name]), + ( + "Blended", + "Asynchronous (On-Demand)", + [Format.synchronous.name, Format.asynchronous.name], + ), + ("Online", "Synchronous", [Format.synchronous.name]), + ("Online", "Asynchronous (On-Demand)", [Format.asynchronous.name]), + ("Online", "Asynchronous (Date based)", [Format.asynchronous.name]), + ("Online", None, [Format.synchronous.name]), + (None, None, [Format.synchronous.name]), + ], +) +def test_parse_format(delivery, run_format, expected_format): + """Test that the format is parsed correctly""" + run_data = { + "Format": run_format, + "Delivery": delivery, + } + assert parse_format(run_data) == expected_format + assert parse_format(None) == [Format.asynchronous.name] diff --git a/learning_resources/etl/xpro.py b/learning_resources/etl/xpro.py index 2dc7f08214..c6f3f4b225 100644 --- a/learning_resources/etl/xpro.py +++ b/learning_resources/etl/xpro.py @@ -10,8 +10,10 @@ from learning_resources.constants import ( CertificationType, + Format, LearningResourceType, OfferedBy, + Pace, PlatformType, ) from learning_resources.etl.constants import ETLSource @@ -117,6 +119,8 @@ def _transform_run(course_run: dict, course: dict) -> dict: ], "availability": course["availability"], "delivery": transform_delivery(course.get("format")), + "pace": [Pace.self_paced.name], + "format": [Format.asynchronous.name], } @@ -158,6 +162,8 @@ def _transform_learning_resource_course(course): "certification_type": CertificationType.professional.name, "availability": course["availability"], "continuing_ed_credits": course["credits"], + "pace": [Pace.self_paced.name], + "format": [Format.asynchronous.name], } @@ -216,6 +222,8 @@ def transform_programs(programs): ], "delivery": transform_delivery(program.get("format")), "availability": program["availability"], + "pace": [Pace.self_paced.name], + "format": [Format.asynchronous.name], } ], "courses": transform_courses(program["courses"]), @@ -223,6 +231,8 @@ def transform_programs(programs): "certification_type": CertificationType.professional.name, "availability": program["availability"], "continuing_ed_credits": program["credits"], + "pace": [Pace.self_paced.name], + "format": [Format.asynchronous.name], } for program in programs ] diff --git a/learning_resources/etl/xpro_test.py b/learning_resources/etl/xpro_test.py index 0f1954a5c2..7acb9e8226 100644 --- a/learning_resources/etl/xpro_test.py +++ b/learning_resources/etl/xpro_test.py @@ -10,7 +10,9 @@ from learning_resources.constants import ( Availability, CertificationType, + Format, LearningResourceType, + Pace, PlatformType, ) from learning_resources.etl import xpro @@ -112,6 +114,8 @@ def test_xpro_transform_programs(mock_xpro_programs_data): "resource_type": LearningResourceType.program.name, "delivery": transform_delivery(program_data.get("format")), "continuing_ed_credits": program_data.get("credits"), + "pace": [Pace.self_paced.name], + "format": [Format.asynchronous.name], "runs": [ { "run_id": program_data["readable_id"], @@ -131,6 +135,8 @@ def test_xpro_transform_programs(mock_xpro_programs_data): "description": program_data["description"], "delivery": transform_delivery(program_data.get("format")), "availability": Availability.dated.name, + "pace": [Pace.self_paced.name], + "format": [Format.asynchronous.name], } ], "courses": [ @@ -153,6 +159,8 @@ def test_xpro_transform_programs(mock_xpro_programs_data): "topics": parse_topics(course_data), "resource_type": LearningResourceType.course.name, "continuing_ed_credits": course_data.get("credits"), + "pace": [Pace.self_paced.name], + "format": [Format.asynchronous.name], "runs": [ { "run_id": course_run_data["courseware_id"], @@ -173,6 +181,8 @@ def test_xpro_transform_programs(mock_xpro_programs_data): ], "delivery": transform_delivery(course_data.get("format")), "availability": Availability.dated.name, + "pace": [Pace.self_paced.name], + "format": [Format.asynchronous.name], } for course_run_data in course_data["courseruns"] ], @@ -245,6 +255,8 @@ def test_xpro_transform_courses(mock_xpro_courses_data): ], "delivery": transform_delivery(course_data.get("format")), "availability": Availability.dated.name, + "pace": [Pace.self_paced.name], + "format": [Format.asynchronous.name], } for course_run_data in course_data["courseruns"] ], @@ -262,6 +274,8 @@ def test_xpro_transform_courses(mock_xpro_courses_data): "certification": True, "certification_type": CertificationType.professional.name, "continuing_ed_credits": course_data.get("credits"), + "pace": [Pace.self_paced.name], + "format": [Format.asynchronous.name], } for course_data in mock_xpro_courses_data ] diff --git a/learning_resources/migrations/0068_learningresource_format_pace.py b/learning_resources/migrations/0068_learningresource_format_pace.py new file mode 100644 index 0000000000..6929be78d1 --- /dev/null +++ b/learning_resources/migrations/0068_learningresource_format_pace.py @@ -0,0 +1,75 @@ +# Generated by Django 4.2.16 on 2024-09-23 14:18 + +import django.contrib.postgres.fields +from django.db import migrations, models + +import learning_resources.models + + +class Migration(migrations.Migration): + dependencies = [ + ("learning_resources", "0067_ocw_delivery_online_only"), + ] + + operations = [ + migrations.AddField( + model_name="learningresource", + name="format", + field=django.contrib.postgres.fields.ArrayField( + base_field=models.CharField( + choices=[ + ("synchronous", "Synchronous"), + ("asynchronous", "Asynchronous"), + ], + max_length=24, + ), + default=learning_resources.models.default_format, + size=None, + ), + ), + migrations.AddField( + model_name="learningresource", + name="pace", + field=django.contrib.postgres.fields.ArrayField( + base_field=models.CharField( + choices=[ + ("self_paced", "Self-paced"), + ("instructor_paced", "Instructor-paced"), + ], + max_length=24, + ), + default=learning_resources.models.default_pace, + size=None, + ), + ), + migrations.AddField( + model_name="learningresourcerun", + name="format", + field=django.contrib.postgres.fields.ArrayField( + base_field=models.CharField( + choices=[ + ("synchronous", "Synchronous"), + ("asynchronous", "Asynchronous"), + ], + max_length=24, + ), + default=learning_resources.models.default_format, + size=None, + ), + ), + migrations.AddField( + model_name="learningresourcerun", + name="pace", + field=django.contrib.postgres.fields.ArrayField( + base_field=models.CharField( + choices=[ + ("self_paced", "Self-paced"), + ("instructor_paced", "Instructor-paced"), + ], + max_length=24, + ), + default=learning_resources.models.default_pace, + size=None, + ), + ), + ] diff --git a/learning_resources/models.py b/learning_resources/models.py index 069214b7a6..4b74e723cd 100644 --- a/learning_resources/models.py +++ b/learning_resources/models.py @@ -16,9 +16,11 @@ from learning_resources.constants import ( Availability, CertificationType, + Format, LearningResourceDelivery, LearningResourceRelationTypes, LearningResourceType, + Pace, PrivacyLevel, ) from main.models import TimestampedModel, TimestampedModelQuerySet @@ -29,6 +31,16 @@ def default_delivery(): return [LearningResourceDelivery.online.name] +def default_pace(): + """Return the default pace as a list""" + return [Pace.self_paced.name] + + +def default_format(): + """Return the default format as a list""" + return [Format.asynchronous.name] + + class LearningResourcePlatform(TimestampedModel): """Platforms for all learning resources""" @@ -428,6 +440,20 @@ class LearningResource(TimestampedModel): continuing_ed_credits = models.DecimalField( max_digits=5, decimal_places=2, null=True, blank=True ) + pace = ArrayField( + models.CharField( + max_length=24, + choices=Pace.as_tuple(), + ), + default=default_pace, + ) + format = ArrayField( + models.CharField( + max_length=24, + choices=Format.as_tuple(), + ), + default=default_format, + ) @property def audience(self) -> str | None: @@ -565,6 +591,20 @@ class LearningResourceRun(TimestampedModel): null=True, choices=Availability.as_tuple(), ) + pace = ArrayField( + models.CharField( + max_length=24, + choices=Pace.as_tuple(), + ), + default=default_pace, + ) + format = ArrayField( + models.CharField( + max_length=24, + choices=Format.as_tuple(), + ), + default=default_format, + ) def __str__(self): return f"LearningResourceRun platform={self.learning_resource.platform} run_id={self.run_id}" # noqa: E501 diff --git a/learning_resources/serializers.py b/learning_resources/serializers.py index 64b76380eb..c52a40ebc6 100644 --- a/learning_resources/serializers.py +++ b/learning_resources/serializers.py @@ -15,9 +15,11 @@ from learning_resources.constants import ( LEARNING_MATERIAL_RESOURCE_CATEGORY, CertificationType, + Format, LearningResourceDelivery, LearningResourceType, LevelType, + Pace, ) from learning_resources.etl.loaders import update_index from main.serializers import COMMON_IGNORED_FIELDS, WriteableSerializerMethodField @@ -236,6 +238,36 @@ def to_representation(self, value): return {"code": value, "name": LearningResourceDelivery[value].value} +@extend_schema_field( + { + "type": "object", + "properties": { + "code": {"enum": Format.names()}, + "name": {"type": "string"}, + }, + "required": ["code", "name"], + } +) +class FormatSerializer(serializers.Field): + def to_representation(self, value): + return {"code": value, "name": Format[value].value} + + +@extend_schema_field( + { + "type": "object", + "properties": { + "code": {"enum": Pace.names()}, + "name": {"type": "string"}, + }, + "required": ["code", "name"], + } +) +class PaceSerializer(serializers.Field): + def to_representation(self, value): + return {"code": value, "name": Pace[value].value} + + class LearningResourceRunSerializer(serializers.ModelSerializer): """Serializer for the LearningResourceRun model""" @@ -248,6 +280,8 @@ class LearningResourceRunSerializer(serializers.ModelSerializer): delivery = serializers.ListField( child=LearningResourceDeliverySerializer(), read_only=True ) + format = serializers.ListField(child=FormatSerializer(), read_only=True) + pace = serializers.ListField(child=PaceSerializer(), read_only=True) class Meta: model = models.LearningResourceRun @@ -415,6 +449,8 @@ class LearningResourceBaseSerializer(serializers.ModelSerializer, WriteableTopic ) free = serializers.SerializerMethodField() resource_category = serializers.SerializerMethodField() + format = serializers.ListField(child=FormatSerializer(), read_only=True) + pace = serializers.ListField(child=PaceSerializer(), read_only=True) def get_resource_category(self, instance) -> str: """Return the resource category of the resource""" diff --git a/learning_resources/serializers_test.py b/learning_resources/serializers_test.py index 3ca30f06dc..bc68711f96 100644 --- a/learning_resources/serializers_test.py +++ b/learning_resources/serializers_test.py @@ -12,9 +12,11 @@ from learning_resources.constants import ( LEARNING_MATERIAL_RESOURCE_CATEGORY, CertificationType, + Format, LearningResourceDelivery, LearningResourceRelationTypes, LearningResourceType, + Pace, PlatformType, ) from learning_resources.models import ContentFile, LearningResource @@ -262,6 +264,13 @@ def test_learning_resource_serializer( # noqa: PLR0913 {"code": lr_delivery, "name": LearningResourceDelivery[lr_delivery].value} for lr_delivery in resource.delivery ], + "format": [ + {"code": lr_format, "name": Format[lr_format].value} + for lr_format in resource.format + ], + "pace": [ + {"code": lr_pace, "name": Pace[lr_pace].value} for lr_pace in resource.pace + ], "next_start_date": resource.next_start_date, "availability": resource.availability, "completeness": 1.0, diff --git a/learning_resources_search/constants.py b/learning_resources_search/constants.py index b0b1d53051..a4208aaf4e 100644 --- a/learning_resources_search/constants.py +++ b/learning_resources_search/constants.py @@ -121,6 +121,20 @@ class FilterConfig: "name": {"type": "keyword"}, }, }, + "pace": { + "type": "nested", + "properties": { + "code": {"type": "keyword"}, + "name": {"type": "keyword"}, + }, + }, + "format": { + "type": "nested", + "properties": { + "code": {"type": "keyword"}, + "name": {"type": "keyword"}, + }, + }, "readable_id": {"type": "keyword"}, "title": ENGLISH_TEXT_FIELD_WITH_SUGGEST, "description": ENGLISH_TEXT_FIELD_WITH_SUGGEST, @@ -231,6 +245,20 @@ class FilterConfig: "name": {"type": "keyword"}, }, }, + "pace": { + "type": "nested", + "properties": { + "code": {"type": "keyword"}, + "name": {"type": "keyword"}, + }, + }, + "format": { + "type": "nested", + "properties": { + "code": {"type": "keyword"}, + "name": {"type": "keyword"}, + }, + }, "semester": {"type": "keyword"}, "year": {"type": "keyword"}, "start_date": {"type": "date"}, diff --git a/main/models.py b/main/models.py index 686cea755a..4bafc925ff 100644 --- a/main/models.py +++ b/main/models.py @@ -18,7 +18,7 @@ def update(self, **kwargs): Automatically update updated_on timestamp when .update(). This is because .update() does not go through .save(), thus will not auto_now, because it happens on the database level without loading objects into memory. - """ # noqa: D402, E501 + """ # noqa: E501 if "updated_on" not in kwargs: kwargs["updated_on"] = now_in_utc() return super().update(**kwargs) diff --git a/openapi/specs/v1.yaml b/openapi/specs/v1.yaml index ea686f47a4..2f6057ec71 100644 --- a/openapi/specs/v1.yaml +++ b/openapi/specs/v1.yaml @@ -7956,6 +7956,36 @@ components: type: string description: Return the resource category of the resource readOnly: true + format: + type: array + items: + type: object + properties: + code: + enum: + - synchronous + - asynchronous + name: + type: string + required: + - code + - name + readOnly: true + pace: + type: array + items: + type: object + properties: + code: + enum: + - self_paced + - instructor_paced + name: + type: string + required: + - code + - name + readOnly: true resource_type: allOf: - $ref: '#/components/schemas/CourseResourceResourceTypeEnum' @@ -8023,11 +8053,13 @@ components: - course_feature - delivery - departments + - format - free - id - image - learning_path_parents - offered_by + - pace - platform - prices - professional @@ -8398,6 +8430,36 @@ components: type: string description: Return the resource category of the resource readOnly: true + format: + type: array + items: + type: object + properties: + code: + enum: + - synchronous + - asynchronous + name: + type: string + required: + - code + - name + readOnly: true + pace: + type: array + items: + type: object + properties: + code: + enum: + - self_paced + - instructor_paced + name: + type: string + required: + - code + - name + readOnly: true resource_type: allOf: - $ref: '#/components/schemas/LearningPathResourceResourceTypeEnum' @@ -8463,12 +8525,14 @@ components: - course_feature - delivery - departments + - format - free - id - image - learning_path - learning_path_parents - offered_by + - pace - platform - prices - readable_id @@ -8964,6 +9028,36 @@ components: - code - name readOnly: true + format: + type: array + items: + type: object + properties: + code: + enum: + - synchronous + - asynchronous + name: + type: string + required: + - code + - name + readOnly: true + pace: + type: array + items: + type: object + properties: + code: + enum: + - self_paced + - instructor_paced + name: + type: string + required: + - code + - name + readOnly: true run_id: type: string maxLength: 128 @@ -9040,10 +9134,12 @@ components: - $ref: '#/components/schemas/NullEnum' required: - delivery + - format - id - image - instructors - level + - pace - run_id - title LearningResourceRunRequest: @@ -10405,6 +10501,36 @@ components: type: string description: Return the resource category of the resource readOnly: true + format: + type: array + items: + type: object + properties: + code: + enum: + - synchronous + - asynchronous + name: + type: string + required: + - code + - name + readOnly: true + pace: + type: array + items: + type: object + properties: + code: + enum: + - self_paced + - instructor_paced + name: + type: string + required: + - code + - name + readOnly: true resource_type: allOf: - $ref: '#/components/schemas/PodcastEpisodeResourceResourceTypeEnum' @@ -10471,11 +10597,13 @@ components: - course_feature - delivery - departments + - format - free - id - image - learning_path_parents - offered_by + - pace - platform - podcast_episode - prices @@ -10687,6 +10815,36 @@ components: type: string description: Return the resource category of the resource readOnly: true + format: + type: array + items: + type: object + properties: + code: + enum: + - synchronous + - asynchronous + name: + type: string + required: + - code + - name + readOnly: true + pace: + type: array + items: + type: object + properties: + code: + enum: + - self_paced + - instructor_paced + name: + type: string + required: + - code + - name + readOnly: true resource_type: allOf: - $ref: '#/components/schemas/PodcastResourceResourceTypeEnum' @@ -10753,11 +10911,13 @@ components: - course_feature - delivery - departments + - format - free - id - image - learning_path_parents - offered_by + - pace - platform - podcast - prices @@ -11099,6 +11259,36 @@ components: type: string description: Return the resource category of the resource readOnly: true + format: + type: array + items: + type: object + properties: + code: + enum: + - synchronous + - asynchronous + name: + type: string + required: + - code + - name + readOnly: true + pace: + type: array + items: + type: object + properties: + code: + enum: + - self_paced + - instructor_paced + name: + type: string + required: + - code + - name + readOnly: true resource_type: allOf: - $ref: '#/components/schemas/ProgramResourceResourceTypeEnum' @@ -11165,11 +11355,13 @@ components: - course_feature - delivery - departments + - format - free - id - image - learning_path_parents - offered_by + - pace - platform - prices - professional @@ -11653,6 +11845,36 @@ components: type: string description: Return the resource category of the resource readOnly: true + format: + type: array + items: + type: object + properties: + code: + enum: + - synchronous + - asynchronous + name: + type: string + required: + - code + - name + readOnly: true + pace: + type: array + items: + type: object + properties: + code: + enum: + - self_paced + - instructor_paced + name: + type: string + required: + - code + - name + readOnly: true resource_type: allOf: - $ref: '#/components/schemas/VideoPlaylistResourceResourceTypeEnum' @@ -11719,11 +11941,13 @@ components: - course_feature - delivery - departments + - format - free - id - image - learning_path_parents - offered_by + - pace - platform - prices - professional @@ -11921,6 +12145,36 @@ components: type: string description: Return the resource category of the resource readOnly: true + format: + type: array + items: + type: object + properties: + code: + enum: + - synchronous + - asynchronous + name: + type: string + required: + - code + - name + readOnly: true + pace: + type: array + items: + type: object + properties: + code: + enum: + - self_paced + - instructor_paced + name: + type: string + required: + - code + - name + readOnly: true resource_type: allOf: - $ref: '#/components/schemas/VideoResourceResourceTypeEnum' @@ -11987,11 +12241,13 @@ components: - course_feature - delivery - departments + - format - free - id - image - learning_path_parents - offered_by + - pace - platform - prices - professional From 58b85032732b3bd4574b5a62e8b5cc98ff74f040 Mon Sep 17 00:00:00 2001 From: Anastasia Beglova Date: Tue, 24 Sep 2024 11:57:56 -0400 Subject: [PATCH 10/48] new -> recently added (#1594) --- .../mit-learn/src/page-components/Header/Header.tsx | 2 +- .../page-components/SearchDisplay/SearchDisplay.tsx | 10 +++------- .../pages/ChannelPage/ChannelSearchFacetDisplay.tsx | 5 ++--- 3 files changed, 6 insertions(+), 11 deletions(-) diff --git a/frontends/mit-learn/src/page-components/Header/Header.tsx b/frontends/mit-learn/src/page-components/Header/Header.tsx index 1f7cf88f89..a0756c3d86 100644 --- a/frontends/mit-learn/src/page-components/Header/Header.tsx +++ b/frontends/mit-learn/src/page-components/Header/Header.tsx @@ -219,7 +219,7 @@ const navData: NavData = { title: "DISCOVER LEARNING RESOURCES", items: [ { - title: "New", + title: "Recently Added", icon: , href: SEARCH_NEW, }, diff --git a/frontends/mit-learn/src/page-components/SearchDisplay/SearchDisplay.tsx b/frontends/mit-learn/src/page-components/SearchDisplay/SearchDisplay.tsx index 7c27760057..704261b0b8 100644 --- a/frontends/mit-learn/src/page-components/SearchDisplay/SearchDisplay.tsx +++ b/frontends/mit-learn/src/page-components/SearchDisplay/SearchDisplay.tsx @@ -52,10 +52,6 @@ import { ResourceCard } from "../ResourceCard/ResourceCard" import { useSearchParams } from "@mitodl/course-search-utils/react-router" import { useUserMe } from "api/hooks/user" -export const StyledSelect = styled(SimpleSelect)` - min-width: 160px; -` - const StyledResourceTabs = styled(ResourceCategoryTabs.TabList)` margin-top: 0 px; ` @@ -470,7 +466,7 @@ const SORT_OPTIONS = [ value: "", }, { - label: "New", + label: "Recently Added", value: "new", }, { @@ -596,7 +592,7 @@ const SearchDisplay: React.FC = ({ } const searchModeDropdown = ( - @@ -615,7 +611,7 @@ const SearchDisplay: React.FC = ({ ) const sortDropdown = ( - setParamValue("sortby", e.target.value)} diff --git a/frontends/mit-learn/src/pages/ChannelPage/ChannelSearchFacetDisplay.tsx b/frontends/mit-learn/src/pages/ChannelPage/ChannelSearchFacetDisplay.tsx index 2da87b65d5..c5404b191e 100644 --- a/frontends/mit-learn/src/pages/ChannelPage/ChannelSearchFacetDisplay.tsx +++ b/frontends/mit-learn/src/pages/ChannelPage/ChannelSearchFacetDisplay.tsx @@ -8,9 +8,8 @@ import type { BooleanFacetKey, } from "@mitodl/course-search-utils" import { BOOLEAN_FACET_NAMES } from "@mitodl/course-search-utils" -import { Skeleton, styled } from "ol-components" +import { Skeleton, styled, SimpleSelect } from "ol-components" import type { SimpleSelectOption } from "ol-components" -import { StyledSelect } from "@/page-components/SearchDisplay/SearchDisplay" const StyledSkeleton = styled(Skeleton)` display: inline-flex; @@ -117,7 +116,7 @@ const AvailableFacetsDropdowns: React.FC< return ( facetItems.length && ( - Date: Tue, 24 Sep 2024 16:03:37 +0000 Subject: [PATCH 11/48] Release 0.19.4 --- RELEASE.rst | 6 ++++++ main/settings.py | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/RELEASE.rst b/RELEASE.rst index 1ae708ba43..c9493d04b6 100644 --- a/RELEASE.rst +++ b/RELEASE.rst @@ -1,6 +1,12 @@ Release Notes ============= +Version 0.19.4 +-------------- + +- new -> recently added (#1594) +- Pace and format fields for learning resources (#1588) + Version 0.19.3 (Released September 23, 2024) -------------- diff --git a/main/settings.py b/main/settings.py index c4e3864445..8941615e60 100644 --- a/main/settings.py +++ b/main/settings.py @@ -33,7 +33,7 @@ from main.settings_pluggy import * # noqa: F403 from openapi.settings_spectacular import open_spectacular_settings -VERSION = "0.19.3" +VERSION = "0.19.4" log = logging.getLogger() From 28dccfec56b4c68881e2796a9f46bd507e86195b Mon Sep 17 00:00:00 2001 From: Matt Bertrand Date: Tue, 24 Sep 2024 17:13:57 -0400 Subject: [PATCH 12/48] Add separate field for ocw topics, use best field to assign related topics (#1600) --- frontends/api/src/generated/v1/api.ts | 186 ++++++++++++++++++ learning_resources/etl/ocw.py | 47 ++++- learning_resources/etl/ocw_test.py | 56 +++++- .../0069_learningresource_ocw_topics.py | 23 +++ learning_resources/models.py | 1 + learning_resources/serializers_test.py | 1 + learning_resources_search/constants.py | 2 + learning_resources_search/serializers.py | 5 + openapi/specs/v1.yaml | 129 ++++++++++++ .../data.json | 12 +- 10 files changed, 450 insertions(+), 12 deletions(-) create mode 100644 learning_resources/migrations/0069_learningresource_ocw_topics.py diff --git a/frontends/api/src/generated/v1/api.ts b/frontends/api/src/generated/v1/api.ts index ff24f6357f..cf9b817ed5 100644 --- a/frontends/api/src/generated/v1/api.ts +++ b/frontends/api/src/generated/v1/api.ts @@ -758,6 +758,12 @@ export interface CourseResource { * @memberof CourseResource */ url?: string | null + /** + * + * @type {Array} + * @memberof CourseResource + */ + ocw_topics?: Array /** * * @type {boolean} @@ -972,6 +978,12 @@ export interface CourseResourceRequest { * @memberof CourseResourceRequest */ url?: string | null + /** + * + * @type {Array} + * @memberof CourseResourceRequest + */ + ocw_topics?: Array /** * * @type {string} @@ -1516,6 +1528,12 @@ export interface LearningPathResource { * @memberof LearningPathResource */ url?: string | null + /** + * + * @type {Array} + * @memberof LearningPathResource + */ + ocw_topics?: Array /** * * @type {boolean} @@ -1608,6 +1626,12 @@ export interface LearningPathResourceRequest { * @memberof LearningPathResourceRequest */ url?: string | null + /** + * + * @type {Array} + * @memberof LearningPathResourceRequest + */ + ocw_topics?: Array /** * * @type {boolean} @@ -3439,6 +3463,12 @@ export interface PatchedLearningPathResourceRequest { * @memberof PatchedLearningPathResourceRequest */ url?: string | null + /** + * + * @type {Array} + * @memberof PatchedLearningPathResourceRequest + */ + ocw_topics?: Array /** * * @type {boolean} @@ -3652,6 +3682,12 @@ export interface PercolateQuerySubscriptionRequestRequest { * @memberof PercolateQuerySubscriptionRequestRequest */ topic?: Array + /** + * The ocw topic name. + * @type {Array} + * @memberof PercolateQuerySubscriptionRequestRequest + */ + ocw_topic?: Array /** * If true return raw open search results with score explanations * @type {boolean} @@ -4213,6 +4249,12 @@ export interface PodcastEpisodeResource { * @memberof PodcastEpisodeResource */ url?: string | null + /** + * + * @type {Array} + * @memberof PodcastEpisodeResource + */ + ocw_topics?: Array /** * * @type {boolean} @@ -4311,6 +4353,12 @@ export interface PodcastEpisodeResourceRequest { * @memberof PodcastEpisodeResourceRequest */ url?: string | null + /** + * + * @type {Array} + * @memberof PodcastEpisodeResourceRequest + */ + ocw_topics?: Array /** * * @type {string} @@ -4571,6 +4619,12 @@ export interface PodcastResource { * @memberof PodcastResource */ url?: string | null + /** + * + * @type {Array} + * @memberof PodcastResource + */ + ocw_topics?: Array /** * * @type {boolean} @@ -4669,6 +4723,12 @@ export interface PodcastResourceRequest { * @memberof PodcastResourceRequest */ url?: string | null + /** + * + * @type {Array} + * @memberof PodcastResourceRequest + */ + ocw_topics?: Array /** * * @type {string} @@ -5149,6 +5209,12 @@ export interface ProgramResource { * @memberof ProgramResource */ url?: string | null + /** + * + * @type {Array} + * @memberof ProgramResource + */ + ocw_topics?: Array /** * * @type {boolean} @@ -5247,6 +5313,12 @@ export interface ProgramResourceRequest { * @memberof ProgramResourceRequest */ url?: string | null + /** + * + * @type {Array} + * @memberof ProgramResourceRequest + */ + ocw_topics?: Array /** * * @type {string} @@ -5986,6 +6058,12 @@ export interface VideoPlaylistResource { * @memberof VideoPlaylistResource */ url?: string | null + /** + * + * @type {Array} + * @memberof VideoPlaylistResource + */ + ocw_topics?: Array /** * * @type {boolean} @@ -6084,6 +6162,12 @@ export interface VideoPlaylistResourceRequest { * @memberof VideoPlaylistResourceRequest */ url?: string | null + /** + * + * @type {Array} + * @memberof VideoPlaylistResourceRequest + */ + ocw_topics?: Array /** * * @type {string} @@ -6332,6 +6416,12 @@ export interface VideoResource { * @memberof VideoResource */ url?: string | null + /** + * + * @type {Array} + * @memberof VideoResource + */ + ocw_topics?: Array /** * * @type {boolean} @@ -6430,6 +6520,12 @@ export interface VideoResourceRequest { * @memberof VideoResourceRequest */ url?: string | null + /** + * + * @type {Array} + * @memberof VideoResourceRequest + */ + ocw_topics?: Array /** * * @type {string} @@ -7190,6 +7286,7 @@ export const ContentFileSearchApiAxiosParamCreator = function ( * @param {boolean | null} [dev_mode] If true return raw open search results with score explanations * @param {Array} [id] The id value for the content file * @param {number} [limit] Number of results to return per page + * @param {Array} [ocw_topic] The ocw topic name. * @param {Array} [offered_by] The organization that offers the learning resource * `mitx` - MITx * `ocw` - MIT OpenCourseWare * `bootcamps` - Bootcamps * `xpro` - MIT xPRO * `mitpe` - MIT Professional Education * `see` - MIT Sloan Executive Education * @param {number} [offset] The initial index from which to return the results * @param {Array} [platform] The platform on which the learning resource is offered * `edx` - edX * `ocw` - MIT OpenCourseWare * `oll` - Open Learning Library * `mitxonline` - MITx Online * `bootcamps` - Bootcamps * `xpro` - MIT xPRO * `csail` - CSAIL * `mitpe` - MIT Professional Education * `see` - MIT Sloan Executive Education * `scc` - Schwarzman College of Computing * `ctl` - Center for Transportation & Logistics * `whu` - WHU * `susskind` - Susskind * `globalalumni` - Global Alumni * `simplilearn` - Simplilearn * `emeritus` - Emeritus * `podcast` - Podcast * `youtube` - YouTube @@ -7207,6 +7304,7 @@ export const ContentFileSearchApiAxiosParamCreator = function ( dev_mode?: boolean | null, id?: Array, limit?: number, + ocw_topic?: Array, offered_by?: Array, offset?: number, platform?: Array, @@ -7253,6 +7351,10 @@ export const ContentFileSearchApiAxiosParamCreator = function ( localVarQueryParameter["limit"] = limit } + if (ocw_topic) { + localVarQueryParameter["ocw_topic"] = ocw_topic + } + if (offered_by) { localVarQueryParameter["offered_by"] = offered_by } @@ -7318,6 +7420,7 @@ export const ContentFileSearchApiFp = function (configuration?: Configuration) { * @param {boolean | null} [dev_mode] If true return raw open search results with score explanations * @param {Array} [id] The id value for the content file * @param {number} [limit] Number of results to return per page + * @param {Array} [ocw_topic] The ocw topic name. * @param {Array} [offered_by] The organization that offers the learning resource * `mitx` - MITx * `ocw` - MIT OpenCourseWare * `bootcamps` - Bootcamps * `xpro` - MIT xPRO * `mitpe` - MIT Professional Education * `see` - MIT Sloan Executive Education * @param {number} [offset] The initial index from which to return the results * @param {Array} [platform] The platform on which the learning resource is offered * `edx` - edX * `ocw` - MIT OpenCourseWare * `oll` - Open Learning Library * `mitxonline` - MITx Online * `bootcamps` - Bootcamps * `xpro` - MIT xPRO * `csail` - CSAIL * `mitpe` - MIT Professional Education * `see` - MIT Sloan Executive Education * `scc` - Schwarzman College of Computing * `ctl` - Center for Transportation & Logistics * `whu` - WHU * `susskind` - Susskind * `globalalumni` - Global Alumni * `simplilearn` - Simplilearn * `emeritus` - Emeritus * `podcast` - Podcast * `youtube` - YouTube @@ -7335,6 +7438,7 @@ export const ContentFileSearchApiFp = function (configuration?: Configuration) { dev_mode?: boolean | null, id?: Array, limit?: number, + ocw_topic?: Array, offered_by?: Array, offset?: number, platform?: Array, @@ -7357,6 +7461,7 @@ export const ContentFileSearchApiFp = function (configuration?: Configuration) { dev_mode, id, limit, + ocw_topic, offered_by, offset, platform, @@ -7412,6 +7517,7 @@ export const ContentFileSearchApiFactory = function ( requestParameters.dev_mode, requestParameters.id, requestParameters.limit, + requestParameters.ocw_topic, requestParameters.offered_by, requestParameters.offset, requestParameters.platform, @@ -7468,6 +7574,13 @@ export interface ContentFileSearchApiContentFileSearchRetrieveRequest { */ readonly limit?: number + /** + * The ocw topic name. + * @type {Array} + * @memberof ContentFileSearchApiContentFileSearchRetrieve + */ + readonly ocw_topic?: Array + /** * The organization that offers the learning resource * `mitx` - MITx * `ocw` - MIT OpenCourseWare * `bootcamps` - Bootcamps * `xpro` - MIT xPRO * `mitpe` - MIT Professional Education * `see` - MIT Sloan Executive Education * @type {Array<'mitx' | 'ocw' | 'bootcamps' | 'xpro' | 'mitpe' | 'see'>} @@ -7551,6 +7664,7 @@ export class ContentFileSearchApi extends BaseAPI { requestParameters.dev_mode, requestParameters.id, requestParameters.limit, + requestParameters.ocw_topic, requestParameters.offered_by, requestParameters.offset, requestParameters.platform, @@ -12619,6 +12733,7 @@ export const LearningResourcesSearchApiAxiosParamCreator = function ( * @param {number} [limit] Number of results to return per page * @param {number | null} [max_incompleteness_penalty] Maximum score penalty for incomplete OCW courses in percent. An OCW course with completeness = 0 will have this score penalty. Partially complete courses have a linear penalty proportional to the degree of incompleteness. Only affects results if there is a search term. * @param {number | null} [min_score] Minimum score value a text query result needs to have to be displayed + * @param {Array} [ocw_topic] The ocw topic name. * @param {Array} [offered_by] The organization that offers the learning resource * `mitx` - MITx * `ocw` - MIT OpenCourseWare * `bootcamps` - Bootcamps * `xpro` - MIT xPRO * `mitpe` - MIT Professional Education * `see` - MIT Sloan Executive Education * @param {number} [offset] The initial index from which to return the results * @param {Array} [platform] The platform on which the learning resource is offered * `edx` - edX * `ocw` - MIT OpenCourseWare * `oll` - Open Learning Library * `mitxonline` - MITx Online * `bootcamps` - Bootcamps * `xpro` - MIT xPRO * `csail` - CSAIL * `mitpe` - MIT Professional Education * `see` - MIT Sloan Executive Education * `scc` - Schwarzman College of Computing * `ctl` - Center for Transportation & Logistics * `whu` - WHU * `susskind` - Susskind * `globalalumni` - Global Alumni * `simplilearn` - Simplilearn * `emeritus` - Emeritus * `podcast` - Podcast * `youtube` - YouTube @@ -12648,6 +12763,7 @@ export const LearningResourcesSearchApiAxiosParamCreator = function ( limit?: number, max_incompleteness_penalty?: number | null, min_score?: number | null, + ocw_topic?: Array, offered_by?: Array, offset?: number, platform?: Array, @@ -12731,6 +12847,10 @@ export const LearningResourcesSearchApiAxiosParamCreator = function ( localVarQueryParameter["min_score"] = min_score } + if (ocw_topic) { + localVarQueryParameter["ocw_topic"] = ocw_topic + } + if (offered_by) { localVarQueryParameter["offered_by"] = offered_by } @@ -12822,6 +12942,7 @@ export const LearningResourcesSearchApiFp = function ( * @param {number} [limit] Number of results to return per page * @param {number | null} [max_incompleteness_penalty] Maximum score penalty for incomplete OCW courses in percent. An OCW course with completeness = 0 will have this score penalty. Partially complete courses have a linear penalty proportional to the degree of incompleteness. Only affects results if there is a search term. * @param {number | null} [min_score] Minimum score value a text query result needs to have to be displayed + * @param {Array} [ocw_topic] The ocw topic name. * @param {Array} [offered_by] The organization that offers the learning resource * `mitx` - MITx * `ocw` - MIT OpenCourseWare * `bootcamps` - Bootcamps * `xpro` - MIT xPRO * `mitpe` - MIT Professional Education * `see` - MIT Sloan Executive Education * @param {number} [offset] The initial index from which to return the results * @param {Array} [platform] The platform on which the learning resource is offered * `edx` - edX * `ocw` - MIT OpenCourseWare * `oll` - Open Learning Library * `mitxonline` - MITx Online * `bootcamps` - Bootcamps * `xpro` - MIT xPRO * `csail` - CSAIL * `mitpe` - MIT Professional Education * `see` - MIT Sloan Executive Education * `scc` - Schwarzman College of Computing * `ctl` - Center for Transportation & Logistics * `whu` - WHU * `susskind` - Susskind * `globalalumni` - Global Alumni * `simplilearn` - Simplilearn * `emeritus` - Emeritus * `podcast` - Podcast * `youtube` - YouTube @@ -12851,6 +12972,7 @@ export const LearningResourcesSearchApiFp = function ( limit?: number, max_incompleteness_penalty?: number | null, min_score?: number | null, + ocw_topic?: Array, offered_by?: Array, offset?: number, platform?: Array, @@ -12885,6 +13007,7 @@ export const LearningResourcesSearchApiFp = function ( limit, max_incompleteness_penalty, min_score, + ocw_topic, offered_by, offset, platform, @@ -12952,6 +13075,7 @@ export const LearningResourcesSearchApiFactory = function ( requestParameters.limit, requestParameters.max_incompleteness_penalty, requestParameters.min_score, + requestParameters.ocw_topic, requestParameters.offered_by, requestParameters.offset, requestParameters.platform, @@ -13068,6 +13192,13 @@ export interface LearningResourcesSearchApiLearningResourcesSearchRetrieveReques */ readonly min_score?: number | null + /** + * The ocw topic name. + * @type {Array} + * @memberof LearningResourcesSearchApiLearningResourcesSearchRetrieve + */ + readonly ocw_topic?: Array + /** * The organization that offers the learning resource * `mitx` - MITx * `ocw` - MIT OpenCourseWare * `bootcamps` - Bootcamps * `xpro` - MIT xPRO * `mitpe` - MIT Professional Education * `see` - MIT Sloan Executive Education * @type {Array<'mitx' | 'ocw' | 'bootcamps' | 'xpro' | 'mitpe' | 'see'>} @@ -13187,6 +13318,7 @@ export class LearningResourcesSearchApi extends BaseAPI { requestParameters.limit, requestParameters.max_incompleteness_penalty, requestParameters.min_score, + requestParameters.ocw_topic, requestParameters.offered_by, requestParameters.offset, requestParameters.platform, @@ -13424,6 +13556,7 @@ export const LearningResourcesUserSubscriptionApiAxiosParamCreator = function ( * @param {number} [limit] Number of results to return per page * @param {number | null} [max_incompleteness_penalty] Maximum score penalty for incomplete OCW courses in percent. An OCW course with completeness = 0 will have this score penalty. Partially complete courses have a linear penalty proportional to the degree of incompleteness. Only affects results if there is a search term. * @param {number | null} [min_score] Minimum score value a text query result needs to have to be displayed + * @param {Array} [ocw_topic] The ocw topic name. * @param {Array} [offered_by] The organization that offers the learning resource * `mitx` - MITx * `ocw` - MIT OpenCourseWare * `bootcamps` - Bootcamps * `xpro` - MIT xPRO * `mitpe` - MIT Professional Education * `see` - MIT Sloan Executive Education * @param {number} [offset] The initial index from which to return the results * @param {Array} [platform] The platform on which the learning resource is offered * `edx` - edX * `ocw` - MIT OpenCourseWare * `oll` - Open Learning Library * `mitxonline` - MITx Online * `bootcamps` - Bootcamps * `xpro` - MIT xPRO * `csail` - CSAIL * `mitpe` - MIT Professional Education * `see` - MIT Sloan Executive Education * `scc` - Schwarzman College of Computing * `ctl` - Center for Transportation & Logistics * `whu` - WHU * `susskind` - Susskind * `globalalumni` - Global Alumni * `simplilearn` - Simplilearn * `emeritus` - Emeritus * `podcast` - Podcast * `youtube` - YouTube @@ -13454,6 +13587,7 @@ export const LearningResourcesUserSubscriptionApiAxiosParamCreator = function ( limit?: number, max_incompleteness_penalty?: number | null, min_score?: number | null, + ocw_topic?: Array, offered_by?: Array, offset?: number, platform?: Array, @@ -13538,6 +13672,10 @@ export const LearningResourcesUserSubscriptionApiAxiosParamCreator = function ( localVarQueryParameter["min_score"] = min_score } + if (ocw_topic) { + localVarQueryParameter["ocw_topic"] = ocw_topic + } + if (offered_by) { localVarQueryParameter["offered_by"] = offered_by } @@ -13620,6 +13758,7 @@ export const LearningResourcesUserSubscriptionApiAxiosParamCreator = function ( * @param {number} [limit] Number of results to return per page * @param {number | null} [max_incompleteness_penalty] Maximum score penalty for incomplete OCW courses in percent. An OCW course with completeness = 0 will have this score penalty. Partially complete courses have a linear penalty proportional to the degree of incompleteness. Only affects results if there is a search term. * @param {number | null} [min_score] Minimum score value a text query result needs to have to be displayed + * @param {Array} [ocw_topic] The ocw topic name. * @param {Array} [offered_by] The organization that offers the learning resource * `mitx` - MITx * `ocw` - MIT OpenCourseWare * `bootcamps` - Bootcamps * `xpro` - MIT xPRO * `mitpe` - MIT Professional Education * `see` - MIT Sloan Executive Education * @param {number} [offset] The initial index from which to return the results * @param {Array} [platform] The platform on which the learning resource is offered * `edx` - edX * `ocw` - MIT OpenCourseWare * `oll` - Open Learning Library * `mitxonline` - MITx Online * `bootcamps` - Bootcamps * `xpro` - MIT xPRO * `csail` - CSAIL * `mitpe` - MIT Professional Education * `see` - MIT Sloan Executive Education * `scc` - Schwarzman College of Computing * `ctl` - Center for Transportation & Logistics * `whu` - WHU * `susskind` - Susskind * `globalalumni` - Global Alumni * `simplilearn` - Simplilearn * `emeritus` - Emeritus * `podcast` - Podcast * `youtube` - YouTube @@ -13649,6 +13788,7 @@ export const LearningResourcesUserSubscriptionApiAxiosParamCreator = function ( limit?: number, max_incompleteness_penalty?: number | null, min_score?: number | null, + ocw_topic?: Array, offered_by?: Array, offset?: number, platform?: Array, @@ -13732,6 +13872,10 @@ export const LearningResourcesUserSubscriptionApiAxiosParamCreator = function ( localVarQueryParameter["min_score"] = min_score } + if (ocw_topic) { + localVarQueryParameter["ocw_topic"] = ocw_topic + } + if (offered_by) { localVarQueryParameter["offered_by"] = offered_by } @@ -13810,6 +13954,7 @@ export const LearningResourcesUserSubscriptionApiAxiosParamCreator = function ( * @param {number} [limit] Number of results to return per page * @param {number | null} [max_incompleteness_penalty] Maximum score penalty for incomplete OCW courses in percent. An OCW course with completeness = 0 will have this score penalty. Partially complete courses have a linear penalty proportional to the degree of incompleteness. Only affects results if there is a search term. * @param {number | null} [min_score] Minimum score value a text query result needs to have to be displayed + * @param {Array} [ocw_topic] The ocw topic name. * @param {Array} [offered_by] The organization that offers the learning resource * `mitx` - MITx * `ocw` - MIT OpenCourseWare * `bootcamps` - Bootcamps * `xpro` - MIT xPRO * `mitpe` - MIT Professional Education * `see` - MIT Sloan Executive Education * @param {number} [offset] The initial index from which to return the results * @param {Array} [platform] The platform on which the learning resource is offered * `edx` - edX * `ocw` - MIT OpenCourseWare * `oll` - Open Learning Library * `mitxonline` - MITx Online * `bootcamps` - Bootcamps * `xpro` - MIT xPRO * `csail` - CSAIL * `mitpe` - MIT Professional Education * `see` - MIT Sloan Executive Education * `scc` - Schwarzman College of Computing * `ctl` - Center for Transportation & Logistics * `whu` - WHU * `susskind` - Susskind * `globalalumni` - Global Alumni * `simplilearn` - Simplilearn * `emeritus` - Emeritus * `podcast` - Podcast * `youtube` - YouTube @@ -13841,6 +13986,7 @@ export const LearningResourcesUserSubscriptionApiAxiosParamCreator = function ( limit?: number, max_incompleteness_penalty?: number | null, min_score?: number | null, + ocw_topic?: Array, offered_by?: Array, offset?: number, platform?: Array, @@ -13926,6 +14072,10 @@ export const LearningResourcesUserSubscriptionApiAxiosParamCreator = function ( localVarQueryParameter["min_score"] = min_score } + if (ocw_topic) { + localVarQueryParameter["ocw_topic"] = ocw_topic + } + if (offered_by) { localVarQueryParameter["offered_by"] = offered_by } @@ -14079,6 +14229,7 @@ export const LearningResourcesUserSubscriptionApiFp = function ( * @param {number} [limit] Number of results to return per page * @param {number | null} [max_incompleteness_penalty] Maximum score penalty for incomplete OCW courses in percent. An OCW course with completeness = 0 will have this score penalty. Partially complete courses have a linear penalty proportional to the degree of incompleteness. Only affects results if there is a search term. * @param {number | null} [min_score] Minimum score value a text query result needs to have to be displayed + * @param {Array} [ocw_topic] The ocw topic name. * @param {Array} [offered_by] The organization that offers the learning resource * `mitx` - MITx * `ocw` - MIT OpenCourseWare * `bootcamps` - Bootcamps * `xpro` - MIT xPRO * `mitpe` - MIT Professional Education * `see` - MIT Sloan Executive Education * @param {number} [offset] The initial index from which to return the results * @param {Array} [platform] The platform on which the learning resource is offered * `edx` - edX * `ocw` - MIT OpenCourseWare * `oll` - Open Learning Library * `mitxonline` - MITx Online * `bootcamps` - Bootcamps * `xpro` - MIT xPRO * `csail` - CSAIL * `mitpe` - MIT Professional Education * `see` - MIT Sloan Executive Education * `scc` - Schwarzman College of Computing * `ctl` - Center for Transportation & Logistics * `whu` - WHU * `susskind` - Susskind * `globalalumni` - Global Alumni * `simplilearn` - Simplilearn * `emeritus` - Emeritus * `podcast` - Podcast * `youtube` - YouTube @@ -14109,6 +14260,7 @@ export const LearningResourcesUserSubscriptionApiFp = function ( limit?: number, max_incompleteness_penalty?: number | null, min_score?: number | null, + ocw_topic?: Array, offered_by?: Array, offset?: number, platform?: Array, @@ -14144,6 +14296,7 @@ export const LearningResourcesUserSubscriptionApiFp = function ( limit, max_incompleteness_penalty, min_score, + ocw_topic, offered_by, offset, platform, @@ -14188,6 +14341,7 @@ export const LearningResourcesUserSubscriptionApiFp = function ( * @param {number} [limit] Number of results to return per page * @param {number | null} [max_incompleteness_penalty] Maximum score penalty for incomplete OCW courses in percent. An OCW course with completeness = 0 will have this score penalty. Partially complete courses have a linear penalty proportional to the degree of incompleteness. Only affects results if there is a search term. * @param {number | null} [min_score] Minimum score value a text query result needs to have to be displayed + * @param {Array} [ocw_topic] The ocw topic name. * @param {Array} [offered_by] The organization that offers the learning resource * `mitx` - MITx * `ocw` - MIT OpenCourseWare * `bootcamps` - Bootcamps * `xpro` - MIT xPRO * `mitpe` - MIT Professional Education * `see` - MIT Sloan Executive Education * @param {number} [offset] The initial index from which to return the results * @param {Array} [platform] The platform on which the learning resource is offered * `edx` - edX * `ocw` - MIT OpenCourseWare * `oll` - Open Learning Library * `mitxonline` - MITx Online * `bootcamps` - Bootcamps * `xpro` - MIT xPRO * `csail` - CSAIL * `mitpe` - MIT Professional Education * `see` - MIT Sloan Executive Education * `scc` - Schwarzman College of Computing * `ctl` - Center for Transportation & Logistics * `whu` - WHU * `susskind` - Susskind * `globalalumni` - Global Alumni * `simplilearn` - Simplilearn * `emeritus` - Emeritus * `podcast` - Podcast * `youtube` - YouTube @@ -14217,6 +14371,7 @@ export const LearningResourcesUserSubscriptionApiFp = function ( limit?: number, max_incompleteness_penalty?: number | null, min_score?: number | null, + ocw_topic?: Array, offered_by?: Array, offset?: number, platform?: Array, @@ -14251,6 +14406,7 @@ export const LearningResourcesUserSubscriptionApiFp = function ( limit, max_incompleteness_penalty, min_score, + ocw_topic, offered_by, offset, platform, @@ -14294,6 +14450,7 @@ export const LearningResourcesUserSubscriptionApiFp = function ( * @param {number} [limit] Number of results to return per page * @param {number | null} [max_incompleteness_penalty] Maximum score penalty for incomplete OCW courses in percent. An OCW course with completeness = 0 will have this score penalty. Partially complete courses have a linear penalty proportional to the degree of incompleteness. Only affects results if there is a search term. * @param {number | null} [min_score] Minimum score value a text query result needs to have to be displayed + * @param {Array} [ocw_topic] The ocw topic name. * @param {Array} [offered_by] The organization that offers the learning resource * `mitx` - MITx * `ocw` - MIT OpenCourseWare * `bootcamps` - Bootcamps * `xpro` - MIT xPRO * `mitpe` - MIT Professional Education * `see` - MIT Sloan Executive Education * @param {number} [offset] The initial index from which to return the results * @param {Array} [platform] The platform on which the learning resource is offered * `edx` - edX * `ocw` - MIT OpenCourseWare * `oll` - Open Learning Library * `mitxonline` - MITx Online * `bootcamps` - Bootcamps * `xpro` - MIT xPRO * `csail` - CSAIL * `mitpe` - MIT Professional Education * `see` - MIT Sloan Executive Education * `scc` - Schwarzman College of Computing * `ctl` - Center for Transportation & Logistics * `whu` - WHU * `susskind` - Susskind * `globalalumni` - Global Alumni * `simplilearn` - Simplilearn * `emeritus` - Emeritus * `podcast` - Podcast * `youtube` - YouTube @@ -14325,6 +14482,7 @@ export const LearningResourcesUserSubscriptionApiFp = function ( limit?: number, max_incompleteness_penalty?: number | null, min_score?: number | null, + ocw_topic?: Array, offered_by?: Array, offset?: number, platform?: Array, @@ -14358,6 +14516,7 @@ export const LearningResourcesUserSubscriptionApiFp = function ( limit, max_incompleteness_penalty, min_score, + ocw_topic, offered_by, offset, platform, @@ -14458,6 +14617,7 @@ export const LearningResourcesUserSubscriptionApiFactory = function ( requestParameters.limit, requestParameters.max_incompleteness_penalty, requestParameters.min_score, + requestParameters.ocw_topic, requestParameters.offered_by, requestParameters.offset, requestParameters.platform, @@ -14501,6 +14661,7 @@ export const LearningResourcesUserSubscriptionApiFactory = function ( requestParameters.limit, requestParameters.max_incompleteness_penalty, requestParameters.min_score, + requestParameters.ocw_topic, requestParameters.offered_by, requestParameters.offset, requestParameters.platform, @@ -14543,6 +14704,7 @@ export const LearningResourcesUserSubscriptionApiFactory = function ( requestParameters.limit, requestParameters.max_incompleteness_penalty, requestParameters.min_score, + requestParameters.ocw_topic, requestParameters.offered_by, requestParameters.offset, requestParameters.platform, @@ -14679,6 +14841,13 @@ export interface LearningResourcesUserSubscriptionApiLearningResourcesUserSubscr */ readonly min_score?: number | null + /** + * The ocw topic name. + * @type {Array} + * @memberof LearningResourcesUserSubscriptionApiLearningResourcesUserSubscriptionCheckList + */ + readonly ocw_topic?: Array + /** * The organization that offers the learning resource * `mitx` - MITx * `ocw` - MIT OpenCourseWare * `bootcamps` - Bootcamps * `xpro` - MIT xPRO * `mitpe` - MIT Professional Education * `see` - MIT Sloan Executive Education * @type {Array<'mitx' | 'ocw' | 'bootcamps' | 'xpro' | 'mitpe' | 'see'>} @@ -14868,6 +15037,13 @@ export interface LearningResourcesUserSubscriptionApiLearningResourcesUserSubscr */ readonly min_score?: number | null + /** + * The ocw topic name. + * @type {Array} + * @memberof LearningResourcesUserSubscriptionApiLearningResourcesUserSubscriptionList + */ + readonly ocw_topic?: Array + /** * The organization that offers the learning resource * `mitx` - MITx * `ocw` - MIT OpenCourseWare * `bootcamps` - Bootcamps * `xpro` - MIT xPRO * `mitpe` - MIT Professional Education * `see` - MIT Sloan Executive Education * @type {Array<'mitx' | 'ocw' | 'bootcamps' | 'xpro' | 'mitpe' | 'see'>} @@ -15050,6 +15226,13 @@ export interface LearningResourcesUserSubscriptionApiLearningResourcesUserSubscr */ readonly min_score?: number | null + /** + * The ocw topic name. + * @type {Array} + * @memberof LearningResourcesUserSubscriptionApiLearningResourcesUserSubscriptionSubscribeCreate + */ + readonly ocw_topic?: Array + /** * The organization that offers the learning resource * `mitx` - MITx * `ocw` - MIT OpenCourseWare * `bootcamps` - Bootcamps * `xpro` - MIT xPRO * `mitpe` - MIT Professional Education * `see` - MIT Sloan Executive Education * @type {Array<'mitx' | 'ocw' | 'bootcamps' | 'xpro' | 'mitpe' | 'see'>} @@ -15197,6 +15380,7 @@ export class LearningResourcesUserSubscriptionApi extends BaseAPI { requestParameters.limit, requestParameters.max_incompleteness_penalty, requestParameters.min_score, + requestParameters.ocw_topic, requestParameters.offered_by, requestParameters.offset, requestParameters.platform, @@ -15242,6 +15426,7 @@ export class LearningResourcesUserSubscriptionApi extends BaseAPI { requestParameters.limit, requestParameters.max_incompleteness_penalty, requestParameters.min_score, + requestParameters.ocw_topic, requestParameters.offered_by, requestParameters.offset, requestParameters.platform, @@ -15286,6 +15471,7 @@ export class LearningResourcesUserSubscriptionApi extends BaseAPI { requestParameters.limit, requestParameters.max_incompleteness_penalty, requestParameters.min_score, + requestParameters.ocw_topic, requestParameters.offered_by, requestParameters.offset, requestParameters.platform, diff --git a/learning_resources/etl/ocw.py b/learning_resources/etl/ocw.py index d776f63c82..31cac2e2fb 100644 --- a/learning_resources/etl/ocw.py +++ b/learning_resources/etl/ocw.py @@ -71,6 +71,40 @@ def parse_delivery(course_data: dict) -> list[str]: return delivery +def parse_learn_topics(course_data: dict) -> list[dict]: + """ + Parse topics. Use the "mit_learn_topics" field if it exists and isn't empty, + otherwise use and transform the "topics" field values. + + Args: + course_data (dict): The course data + + Returns: + list[dict]: The topics + """ + mitlearn_topics = course_data.get("mit_learn_topics") or [] + ocw_topics = course_data.get("topics") or [] + if mitlearn_topics: + # Should already be in the correct format + return [ + {"name": topic_name} + for topic_name in sorted( + {topic for topics in mitlearn_topics for topic in topics} + ) + ] + else: + # Topics need to be transformed + return transform_topics( + [ + {"name": topic_name} + for topic_name in sorted( + {topic for topics in ocw_topics for topic in topics} + ) + ], + OFFERED_BY["code"], + ) + + def transform_content_files( s3_resource: boto3.resource, course_prefix: str, @@ -329,10 +363,6 @@ def transform_course(course_data: dict) -> dict: readable_term = f"+{slugify(term)}" if term else "" readable_year = f"_{course_data.get('year')}" if year else "" readable_id = f"{course_data[PRIMARY_COURSE_ID]}{readable_term}{readable_year}" - topics = transform_topics( - [{"name": topic} for topics in course_data.get("topics") for topic in topics], - OFFERED_BY["code"], - ) image_src = course_data.get("image_src") return { @@ -365,7 +395,14 @@ def transform_course(course_data: dict) -> dict: is_ocw=True, ), }, - "topics": topics, + "topics": parse_learn_topics(course_data), + "ocw_topics": sorted( + { + topic_name + for topic_sublist in course_data.get("topics", []) + for topic_name in topic_sublist + } + ), "runs": [transform_run(course_data)], "resource_type": LearningResourceType.course.name, "unique_field": UNIQUE_FIELD, diff --git a/learning_resources/etl/ocw_test.py b/learning_resources/etl/ocw_test.py index d54b562ce7..7ffba2cf4c 100644 --- a/learning_resources/etl/ocw_test.py +++ b/learning_resources/etl/ocw_test.py @@ -18,11 +18,15 @@ ) from learning_resources.etl.constants import CourseNumberType, ETLSource from learning_resources.etl.ocw import ( + parse_learn_topics, transform_content_files, transform_contentfile, transform_course, ) -from learning_resources.factories import ContentFileFactory +from learning_resources.factories import ( + ContentFileFactory, + LearningResourceTopicFactory, +) from learning_resources.models import ContentFile from learning_resources.utils import ( get_s3_object_and_read, @@ -239,6 +243,14 @@ def test_transform_course( # noqa: PLR0913 ) transformed_json = transform_course(extracted_json) if expected_uid: + assert transformed_json["ocw_topics"] == [ + "Anthropology", + "Ethnography", + "Humanities", + "Philosophy", + "Political Philosophy", + "Social Science", + ] assert transformed_json["readable_id"] == expected_id assert transformed_json["etl_source"] == ETLSource.ocw.name assert transformed_json["delivery"] == expected_delivery @@ -295,3 +307,45 @@ def test_transform_course( # noqa: PLR0913 ) else: assert transformed_json is None + + +@pytest.mark.parametrize("has_learn_topics", [True, False]) +def test_parse_topics(mocker, has_learn_topics): + """Topics should be assigned correctly based on mitlearn topics if present, ocw topics if not""" + ocw_topics = [ + ["Social Science", "Anthropology", "Ethnography"], + ["Social Science", "Political Science", "International Relations"], + ] + mit_learn_topics = ( + [["Social Sciences", "Anthropology"], ["Social Sciences", "Political Science"]] + if has_learn_topics + else [] + ) + course_data = { + "topics": ocw_topics, + "mit_learn_topics": mit_learn_topics, + } + mocker.patch( + "learning_resources.etl.utils.load_offeror_topic_map", + return_value={ + "Political Philosophy": ["Philosophy"], + "Ethnography": ["Anthropology"], + "International Relations": ["Political Science"], + }, + ) + for topic in ("Social Sciences", "Anthropology", "Political Science"): + LearningResourceTopicFactory.create(name=topic) + topics_dict = parse_learn_topics(course_data) + if has_learn_topics: + assert topics_dict == [ + {"name": "Anthropology"}, + {"name": "Political Science"}, + {"name": "Social Sciences"}, + ] + else: + assert topics_dict == [ + {"name": "Anthropology"}, + {"name": "Anthropology"}, + {"name": "Political Science"}, + {"name": "Political Science"}, + ] diff --git a/learning_resources/migrations/0069_learningresource_ocw_topics.py b/learning_resources/migrations/0069_learningresource_ocw_topics.py new file mode 100644 index 0000000000..3ee3678b5a --- /dev/null +++ b/learning_resources/migrations/0069_learningresource_ocw_topics.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.16 on 2024-09-23 18:04 + +import django.contrib.postgres.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("learning_resources", "0068_learningresource_format_pace"), + ] + + operations = [ + migrations.AddField( + model_name="learningresource", + name="ocw_topics", + field=django.contrib.postgres.fields.ArrayField( + base_field=models.CharField(max_length=128), + blank=True, + default=list, + size=None, + ), + ), + ] diff --git a/learning_resources/models.py b/learning_resources/models.py index 4b74e723cd..e3c3bf3d75 100644 --- a/learning_resources/models.py +++ b/learning_resources/models.py @@ -411,6 +411,7 @@ class LearningResource(TimestampedModel): choices=((member.name, member.value) for member in LearningResourceType), ) topics = models.ManyToManyField(LearningResourceTopic) + ocw_topics = ArrayField(models.CharField(max_length=128), default=list, blank=True) offered_by = models.ForeignKey( LearningResourceOfferor, null=True, on_delete=models.SET_NULL ) diff --git a/learning_resources/serializers_test.py b/learning_resources/serializers_test.py index bc68711f96..b454de5ee7 100644 --- a/learning_resources/serializers_test.py +++ b/learning_resources/serializers_test.py @@ -254,6 +254,7 @@ def test_learning_resource_serializer( # noqa: PLR0913 serializers.LearningResourceTopicSerializer(topic).data for topic in resource.topics.all() ], + "ocw_topics": sorted(resource.ocw_topics), "runs": [ serializers.LearningResourceRunSerializer(instance=run).data for run in resource.runs.all() diff --git a/learning_resources_search/constants.py b/learning_resources_search/constants.py index a4208aaf4e..6bcc47e1f0 100644 --- a/learning_resources_search/constants.py +++ b/learning_resources_search/constants.py @@ -71,6 +71,7 @@ class FilterConfig: "run_id": FilterConfig("run_id", case_sensitive=True), "resource_id": FilterConfig("resource_id", case_sensitive=True), "topic": FilterConfig("topics.name"), + "ocw_topic": FilterConfig("ocw_topics"), "level": FilterConfig("runs.level.code"), "department": FilterConfig("departments.department_id"), "platform": FilterConfig("platform.code"), @@ -184,6 +185,7 @@ class FilterConfig: "channel_url": {"type": "keyword"}, }, }, + "ocw_topics": {"type": "keyword"}, "offered_by": { "type": "nested", "properties": { diff --git a/learning_resources_search/serializers.py b/learning_resources_search/serializers.py index fd9bd3fdcf..b340605169 100644 --- a/learning_resources_search/serializers.py +++ b/learning_resources_search/serializers.py @@ -276,6 +276,11 @@ class SearchRequestSerializer(serializers.Serializer): child=serializers.CharField(), help_text="The topic name. To see a list of options go to api/v1/topics/", ) + ocw_topic = serializers.ListField( + required=False, + child=serializers.CharField(), + help_text="The ocw topic name.", + ) dev_mode = serializers.BooleanField( required=False, allow_null=True, diff --git a/openapi/specs/v1.yaml b/openapi/specs/v1.yaml index 2f6057ec71..af71655a91 100644 --- a/openapi/specs/v1.yaml +++ b/openapi/specs/v1.yaml @@ -175,6 +175,14 @@ paths: schema: type: integer description: Number of results to return per page + - in: query + name: ocw_topic + schema: + type: array + items: + type: string + minLength: 1 + description: The ocw topic name. - in: query name: offered_by schema: @@ -2506,6 +2514,14 @@ paths: minimum: 0 nullable: true description: Minimum score value a text query result needs to have to be displayed + - in: query + name: ocw_topic + schema: + type: array + items: + type: string + minLength: 1 + description: The ocw topic name. - in: query name: offered_by schema: @@ -2986,6 +3002,14 @@ paths: minimum: 0 nullable: true description: Minimum score value a text query result needs to have to be displayed + - in: query + name: ocw_topic + schema: + type: array + items: + type: string + minLength: 1 + description: The ocw topic name. - in: query name: offered_by schema: @@ -3491,6 +3515,14 @@ paths: minimum: 0 nullable: true description: Minimum score value a text query result needs to have to be displayed + - in: query + name: ocw_topic + schema: + type: array + items: + type: string + minLength: 1 + description: The ocw topic name. - in: query name: offered_by schema: @@ -3987,6 +4019,14 @@ paths: minimum: 0 nullable: true description: Minimum score value a text query result needs to have to be displayed + - in: query + name: ocw_topic + schema: + type: array + items: + type: string + minLength: 1 + description: The ocw topic name. - in: query name: offered_by schema: @@ -8024,6 +8064,11 @@ components: format: uri nullable: true maxLength: 2048 + ocw_topics: + type: array + items: + type: string + maxLength: 128 professional: type: boolean readOnly: true @@ -8111,6 +8156,12 @@ components: nullable: true minLength: 1 maxLength: 2048 + ocw_topics: + type: array + items: + type: string + minLength: 1 + maxLength: 128 next_start_date: type: string format: date-time @@ -8498,6 +8549,11 @@ components: format: uri nullable: true maxLength: 2048 + ocw_topics: + type: array + items: + type: string + maxLength: 128 professional: type: boolean next_start_date: @@ -8579,6 +8635,12 @@ components: nullable: true minLength: 1 maxLength: 2048 + ocw_topics: + type: array + items: + type: string + minLength: 1 + maxLength: 128 professional: type: boolean next_start_date: @@ -9958,6 +10020,12 @@ components: nullable: true minLength: 1 maxLength: 2048 + ocw_topics: + type: array + items: + type: string + minLength: 1 + maxLength: 128 professional: type: boolean next_start_date: @@ -10088,6 +10156,12 @@ components: type: string minLength: 1 description: The topic name. To see a list of options go to api/v1/topics/ + ocw_topic: + type: array + items: + type: string + minLength: 1 + description: The ocw topic name. dev_mode: type: boolean nullable: true @@ -10569,6 +10643,11 @@ components: format: uri nullable: true maxLength: 2048 + ocw_topics: + type: array + items: + type: string + maxLength: 128 professional: type: boolean readOnly: true @@ -10656,6 +10735,12 @@ components: nullable: true minLength: 1 maxLength: 2048 + ocw_topics: + type: array + items: + type: string + minLength: 1 + maxLength: 128 next_start_date: type: string format: date-time @@ -10883,6 +10968,11 @@ components: format: uri nullable: true maxLength: 2048 + ocw_topics: + type: array + items: + type: string + maxLength: 128 professional: type: boolean readOnly: true @@ -10970,6 +11060,12 @@ components: nullable: true minLength: 1 maxLength: 2048 + ocw_topics: + type: array + items: + type: string + minLength: 1 + maxLength: 128 next_start_date: type: string format: date-time @@ -11327,6 +11423,11 @@ components: format: uri nullable: true maxLength: 2048 + ocw_topics: + type: array + items: + type: string + maxLength: 128 professional: type: boolean readOnly: true @@ -11414,6 +11515,12 @@ components: nullable: true minLength: 1 maxLength: 2048 + ocw_topics: + type: array + items: + type: string + minLength: 1 + maxLength: 128 next_start_date: type: string format: date-time @@ -11913,6 +12020,11 @@ components: format: uri nullable: true maxLength: 2048 + ocw_topics: + type: array + items: + type: string + maxLength: 128 professional: type: boolean readOnly: true @@ -12000,6 +12112,12 @@ components: nullable: true minLength: 1 maxLength: 2048 + ocw_topics: + type: array + items: + type: string + minLength: 1 + maxLength: 128 next_start_date: type: string format: date-time @@ -12213,6 +12331,11 @@ components: format: uri nullable: true maxLength: 2048 + ocw_topics: + type: array + items: + type: string + maxLength: 128 professional: type: boolean readOnly: true @@ -12300,6 +12423,12 @@ components: nullable: true minLength: 1 maxLength: 2048 + ocw_topics: + type: array + items: + type: string + minLength: 1 + maxLength: 128 next_start_date: type: string format: date-time diff --git a/test_json/courses/16-01-unified-engineering-i-ii-iii-iv-fall-2005-spring-2006/data.json b/test_json/courses/16-01-unified-engineering-i-ii-iii-iv-fall-2005-spring-2006/data.json index 542e96fb1b..c676ac7f6e 100644 --- a/test_json/courses/16-01-unified-engineering-i-ii-iii-iv-fall-2005-spring-2006/data.json +++ b/test_json/courses/16-01-unified-engineering-i-ii-iii-iv-fall-2005-spring-2006/data.json @@ -85,12 +85,12 @@ "Exams with Solutions" ], "topics": [ - ["Engineering", "Aerospace Engineering", "Materials Selection"], - ["Engineering", "Aerospace Engineering", "Propulsion Systems"], - ["Science", "Physics", "Thermodynamics"], - ["Engineering", "Mechanical Engineering", "Fluid Mechanics"], - ["Engineering", "Aerospace Engineering"], - ["Business", "Project Management"] + ["Social Science", "Anthropology", "Ethnography"], + ["Humanities", "Philosophy", "Political Philosophy"] + ], + "mit_learn_topics": [ + ["Social Sciences", "Anthropology"], + ["Social Sciences", "Political Science"] ], "primary_course_number": "16.01", "extra_course_numbers": "16.02, 16.03, 16.04", From 1875d8f99d956300700a6304223a75c105d12da9 Mon Sep 17 00:00:00 2001 From: Doof Date: Wed, 25 Sep 2024 13:24:04 +0000 Subject: [PATCH 13/48] Release date for 0.19.4 --- RELEASE.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/RELEASE.rst b/RELEASE.rst index c9493d04b6..a899b8533d 100644 --- a/RELEASE.rst +++ b/RELEASE.rst @@ -1,7 +1,7 @@ Release Notes ============= -Version 0.19.4 +Version 0.19.4 (Released September 25, 2024) -------------- - new -> recently added (#1594) From cc4c24ae9426f96cb76fa3f4dc6b1d0c527617ba Mon Sep 17 00:00:00 2001 From: Doof Date: Wed, 25 Sep 2024 16:50:00 +0000 Subject: [PATCH 14/48] Release 0.19.5 --- RELEASE.rst | 5 +++++ main/settings.py | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/RELEASE.rst b/RELEASE.rst index a899b8533d..3719c952c0 100644 --- a/RELEASE.rst +++ b/RELEASE.rst @@ -1,6 +1,11 @@ Release Notes ============= +Version 0.19.5 +-------------- + +- Add separate field for ocw topics, use best field to assign related topics (#1600) + Version 0.19.4 (Released September 25, 2024) -------------- diff --git a/main/settings.py b/main/settings.py index 8941615e60..4547c9910c 100644 --- a/main/settings.py +++ b/main/settings.py @@ -33,7 +33,7 @@ from main.settings_pluggy import * # noqa: F403 from openapi.settings_spectacular import open_spectacular_settings -VERSION = "0.19.4" +VERSION = "0.19.5" log = logging.getLogger() From f6295edfcbe5c8f075ac3fa8603a4a53a0c303b3 Mon Sep 17 00:00:00 2001 From: Matt Bertrand Date: Thu, 26 Sep 2024 08:17:26 -0400 Subject: [PATCH 15/48] Add task_reject_on_worker_lost=True to finish_recreate_index, use return instead of raise for replaced tasks (#1608) --- learning_resources/tasks.py | 14 +++++++------- learning_resources_search/tasks.py | 12 ++++++++---- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/learning_resources/tasks.py b/learning_resources/tasks.py index 0fd1527a3e..0cd857bcfe 100644 --- a/learning_resources/tasks.py +++ b/learning_resources/tasks.py @@ -170,7 +170,7 @@ def get_content_tasks( @app.task(bind=True) def import_all_mit_edx_files(self, chunk_size=None): """Ingest MIT edX files from an S3 bucket""" - raise self.replace( + return self.replace( get_content_tasks( ETLSource.mit_edx.name, chunk_size=chunk_size, @@ -182,7 +182,7 @@ def import_all_mit_edx_files(self, chunk_size=None): @app.task(bind=True) def import_all_oll_files(self, chunk_size=None): """Ingest MIT edX files from an S3 bucket""" - raise self.replace( + return self.replace( get_content_tasks( ETLSource.oll.name, chunk_size=chunk_size, @@ -195,7 +195,7 @@ def import_all_oll_files(self, chunk_size=None): @app.task(bind=True) def import_all_mitxonline_files(self, chunk_size=None): """Ingest MITx Online files from an S3 bucket""" - raise self.replace( + return self.replace( get_content_tasks( ETLSource.mitxonline.name, chunk_size=chunk_size, @@ -207,7 +207,7 @@ def import_all_mitxonline_files(self, chunk_size=None): def import_all_xpro_files(self, chunk_size=None): """Ingest xPRO OLX files from an S3 bucket""" - raise self.replace( + return self.replace( get_content_tasks( ETLSource.xpro.name, chunk_size=chunk_size, @@ -274,7 +274,7 @@ def get_ocw_data( # noqa: PLR0913 and settings.OCW_LIVE_BUCKET ): log.warning("Required settings missing for get_ocw_data") - return + return None # get all the courses prefixes we care about raw_data_bucket = boto3.resource( @@ -300,7 +300,7 @@ def get_ocw_data( # noqa: PLR0913 if len(ocw_courses) == 0: log.info("No courses matching url substring") - return + return None log.info("Backpopulating %d OCW courses...", len(ocw_courses)) @@ -317,7 +317,7 @@ def get_ocw_data( # noqa: PLR0913 ) ] ) - raise self.replace(ocw_tasks) + return self.replace(ocw_tasks) @app.task diff --git a/learning_resources_search/tasks.py b/learning_resources_search/tasks.py index bf1b3fe598..b3a07cf71c 100644 --- a/learning_resources_search/tasks.py +++ b/learning_resources_search/tasks.py @@ -272,7 +272,7 @@ def send_subscription_emails(self, subscription_type, period="daily"): ) ] ) - raise self.replace(email_tasks) + return self.replace(email_tasks) @app.task(autoretry_for=(RetryError,), retry_backoff=True, rate_limit="600/m") @@ -625,7 +625,7 @@ def start_recreate_index(self, indexes, remove_existing_reindexing_tags): # Use self.replace so that code waiting on this task will also wait on the indexing # and finish tasks - raise self.replace( + return self.replace( celery.chain(index_tasks, finish_recreate_index.s(new_backing_indices)) ) @@ -678,7 +678,7 @@ def start_update_index(self, indexes, etl_source): error = "start_update_index threw an error" log.exception(error) return [error] - raise self.replace(celery.chain(index_tasks, finish_update_index.s())) + return self.replace(celery.chain(index_tasks, finish_update_index.s())) def get_update_resource_files_tasks(blocklisted_ids, etl_source): @@ -841,7 +841,11 @@ def get_update_learning_resource_tasks(resource_type): @app.task( - acks_late=True, autoretry_for=(RetryError,), retry_backoff=True, rate_limit="600/m" + acks_late=True, + reject_on_worker_lost=True, + autoretry_for=(RetryError,), + retry_backoff=True, + rate_limit="600/m", ) def finish_recreate_index(results, backing_indices): """ From f83cffe09c6da6b9eb42bdf4e738fdbf4230a6bc Mon Sep 17 00:00:00 2001 From: Doof Date: Thu, 26 Sep 2024 13:38:22 +0000 Subject: [PATCH 16/48] Release date for 0.19.5 --- RELEASE.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/RELEASE.rst b/RELEASE.rst index 3719c952c0..4f5864c9ce 100644 --- a/RELEASE.rst +++ b/RELEASE.rst @@ -1,7 +1,7 @@ Release Notes ============= -Version 0.19.5 +Version 0.19.5 (Released September 26, 2024) -------------- - Add separate field for ocw topics, use best field to assign related topics (#1600) From 2c84383629c6ca0f412af6a7933cf5f3521b75bc Mon Sep 17 00:00:00 2001 From: Doof Date: Thu, 26 Sep 2024 16:44:29 +0000 Subject: [PATCH 17/48] Release 0.19.6 --- RELEASE.rst | 5 +++++ main/settings.py | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/RELEASE.rst b/RELEASE.rst index 4f5864c9ce..b3338b2eaf 100644 --- a/RELEASE.rst +++ b/RELEASE.rst @@ -1,6 +1,11 @@ Release Notes ============= +Version 0.19.6 +-------------- + +- Add task_reject_on_worker_lost=True to finish_recreate_index, use return instead of raise for replaced tasks (#1608) + Version 0.19.5 (Released September 26, 2024) -------------- diff --git a/main/settings.py b/main/settings.py index 4547c9910c..1373918998 100644 --- a/main/settings.py +++ b/main/settings.py @@ -33,7 +33,7 @@ from main.settings_pluggy import * # noqa: F403 from openapi.settings_spectacular import open_spectacular_settings -VERSION = "0.19.5" +VERSION = "0.19.6" log = logging.getLogger() From 16bd64b0b640dee310662995ee8b44daf347ca4b Mon Sep 17 00:00:00 2001 From: Doof Date: Fri, 27 Sep 2024 14:04:55 +0000 Subject: [PATCH 18/48] Release date for 0.19.6 --- RELEASE.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/RELEASE.rst b/RELEASE.rst index b3338b2eaf..75cb020984 100644 --- a/RELEASE.rst +++ b/RELEASE.rst @@ -1,7 +1,7 @@ Release Notes ============= -Version 0.19.6 +Version 0.19.6 (Released September 27, 2024) -------------- - Add task_reject_on_worker_lost=True to finish_recreate_index, use return instead of raise for replaced tasks (#1608) From a73f67044a7957cd7c936795236c3c7b7c8e6cad Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 27 Sep 2024 10:32:07 -0400 Subject: [PATCH 19/48] [pre-commit.ci] pre-commit autoupdate (#1601) --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 0a30546482..9294da061b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -74,7 +74,7 @@ repos: - ".*/generated/" additional_dependencies: ["gibberish-detector"] - repo: https://github.com/astral-sh/ruff-pre-commit - rev: "v0.6.6" + rev: "v0.6.7" hooks: - id: ruff-format - id: ruff From 378326f20bd7f6b2e4f0536716a722811168902c Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 27 Sep 2024 10:55:41 -0400 Subject: [PATCH 20/48] Update Python to v3.12.6 (#1593) --- .github/workflows/ci.yml | 2 +- Dockerfile | 2 +- poetry.lock | 4 ++-- pyproject.toml | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a35300027f..8708d1ac8e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -58,7 +58,7 @@ jobs: - uses: actions/setup-python@f677139bbe7f9c59b41e40162b753c062f5d49a3 # v5 with: - python-version: "3.12.5" + python-version: "3.12.6" cache: "poetry" - name: Validate lockfile diff --git a/Dockerfile b/Dockerfile index 9d3cf6721e..85076f0560 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.12.5 +FROM python:3.12.6 LABEL maintainer "ODL DevOps " # Add package files, install updated node and pip diff --git a/poetry.lock b/poetry.lock index 109f4cda6f..d3d76f0daa 100644 --- a/poetry.lock +++ b/poetry.lock @@ -4817,5 +4817,5 @@ testing = ["coverage (>=5.0.3)", "zope.event", "zope.testing"] [metadata] lock-version = "2.0" -python-versions = "3.12.5" -content-hash = "7098ba8cd400019854f628baefd55c253ae0a9fcd4af5ed43fab0334ddaab381" +python-versions = "3.12.6" +content-hash = "441740137b9693b13d243459b3af4655ea62e6241e93d73b93188eb5e5ff9d78" diff --git a/pyproject.toml b/pyproject.toml index 6ed13909f2..19b9ed22b8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,7 @@ authors = ["MIT ODL"] [tool.poetry.dependencies] -python = "3.12.5" +python = "3.12.6" attrs = "^24.0.0" base36 = "^0.1.1" beautifulsoup4 = "^4.8.2" From 83f502d9328303e3b75739701657e3a87d582b6c Mon Sep 17 00:00:00 2001 From: Shankar Ambady Date: Fri, 27 Sep 2024 14:53:01 -0400 Subject: [PATCH 21/48] Add custom label for percolate query subscriptions (#1610) * adding display label and migration to percolate queries * regenerating specs * adding test and method for returning label * fixing migration * adding display label to admin * adding test for source description * fixing search conditionals in test --- frontends/api/src/generated/v1/api.ts | 6 ++ learning_resources_search/admin.py | 4 +- .../0005_percolatequery_display_label.py | 21 +++++++ learning_resources_search/models.py | 8 +++ learning_resources_search/models_test.py | 55 +++++++++++++++++++ openapi/specs/v1.yaml | 4 ++ 6 files changed, 96 insertions(+), 2 deletions(-) create mode 100644 learning_resources_search/migrations/0005_percolatequery_display_label.py diff --git a/frontends/api/src/generated/v1/api.ts b/frontends/api/src/generated/v1/api.ts index cf9b817ed5..62385b9658 100644 --- a/frontends/api/src/generated/v1/api.ts +++ b/frontends/api/src/generated/v1/api.ts @@ -3638,6 +3638,12 @@ export interface PercolateQuery { * @memberof PercolateQuery */ source_type: SourceTypeEnum + /** + * Friendly display label for the query + * @type {string} + * @memberof PercolateQuery + */ + display_label?: string } /** diff --git a/learning_resources_search/admin.py b/learning_resources_search/admin.py index c0a6b23160..cf839aae59 100644 --- a/learning_resources_search/admin.py +++ b/learning_resources_search/admin.py @@ -9,8 +9,8 @@ class PercolateQueryAdmin(admin.ModelAdmin): """PercolateQuery Admin""" model = models.PercolateQuery - list_display = ("original_query", "query") - search_fields = ("original_query", "query") + list_display = ("original_query", "query", "display_label") + search_fields = ("original_query", "query", "display_label") admin.site.register(models.PercolateQuery, PercolateQueryAdmin) diff --git a/learning_resources_search/migrations/0005_percolatequery_display_label.py b/learning_resources_search/migrations/0005_percolatequery_display_label.py new file mode 100644 index 0000000000..a2c2814fc0 --- /dev/null +++ b/learning_resources_search/migrations/0005_percolatequery_display_label.py @@ -0,0 +1,21 @@ +# Generated by Django 4.2.16 on 2024-09-25 18:26 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("learning_resources_search", "0004_alter_percolatequery_source_type"), + ] + + operations = [ + migrations.AddField( + model_name="percolatequery", + name="display_label", + field=models.CharField( + blank=True, + help_text="Friendly display label for the query", + max_length=255, + ), + ), + ] diff --git a/learning_resources_search/models.py b/learning_resources_search/models.py index 058381b2af..8b10a69981 100644 --- a/learning_resources_search/models.py +++ b/learning_resources_search/models.py @@ -26,6 +26,12 @@ class PercolateQuery(TimestampedModel): source_type = models.CharField( max_length=255, choices=[(choice, choice) for choice in SOURCE_TYPES] ) + + display_label = models.CharField( + max_length=255, + blank=True, + help_text="Friendly display label for the query", + ) users = models.ManyToManyField(User, related_name="percolate_queries") def source_label(self): @@ -37,6 +43,8 @@ def source_label(self): def source_description(self): channel = self.source_channel() + if self.display_label: + return self.display_label if channel: return channel.title return self.original_url_params() diff --git a/learning_resources_search/models_test.py b/learning_resources_search/models_test.py index fa070c6fe7..3a6058e2ea 100644 --- a/learning_resources_search/models_test.py +++ b/learning_resources_search/models_test.py @@ -1,4 +1,5 @@ from types import SimpleNamespace +from urllib.parse import urlencode import pytest @@ -152,3 +153,57 @@ def test_percolate_query_search_labels(mocker, mocked_es): == "q=testing+search+filter&department=physics&topic=math" ) assert query.source_label() == "saved_search" + + +@pytest.mark.django_db +@pytest.mark.parametrize("is_channel_query", [True, False]) +@pytest.mark.parametrize("test_label", ["new courses about cats", ""]) +def test_percolate_query_display_labels( + mocker, mocked_es, test_label, is_channel_query +): + """ + Test that makes sure we display the display label for a percolate query if it is defined + """ + + def encode_params(oparams): + ignore_params = ["endpoint"] + query = oparams + defined_params = { + key: query[key] for key in query if query[key] and key not in ignore_params + } + return urlencode(defined_params, doseq=True) + + mocker.patch( + "learning_resources_search.indexing_api.index_percolators", autospec=True + ) + mocker.patch( + "learning_resources_search.indexing_api._update_document_by_id", autospec=True + ) + + if is_channel_query: + original_query = {"department": ["physics"]} + channel = ChannelFactory.create( + search_filter=encode_params(original_query), channel_type="unit" + ) + else: + original_query = { + "q": "testing search filter", + "certification": None, + "yearly_decay_percent": None, + } + + query = PercolateQueryFactory.create( + original_query=original_query, + query=original_query, + display_label=test_label, + ) + assert query.original_url_params() == encode_params(original_query) + if not is_channel_query: + assert query.source_label() == "saved_search" + assert query.source_description() == ( + test_label + if test_label + else channel.title + if is_channel_query + else query.original_url_params() + ) diff --git a/openapi/specs/v1.yaml b/openapi/specs/v1.yaml index af71655a91..1fe6d76a0e 100644 --- a/openapi/specs/v1.yaml +++ b/openapi/specs/v1.yaml @@ -10106,6 +10106,10 @@ components: query: {} source_type: $ref: '#/components/schemas/SourceTypeEnum' + display_label: + type: string + description: Friendly display label for the query + maxLength: 255 required: - id - original_query From 3a906b81b24875ba53be3e143c2fdd349d15240f Mon Sep 17 00:00:00 2001 From: shankar ambady Date: Fri, 27 Sep 2024 15:27:40 -0400 Subject: [PATCH 22/48] changes for formatting search subscription emails --- learning_resources_search/models.py | 2 +- learning_resources_search/tasks.py | 8 +++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/learning_resources_search/models.py b/learning_resources_search/models.py index 8b10a69981..56b24b5b70 100644 --- a/learning_resources_search/models.py +++ b/learning_resources_search/models.py @@ -45,7 +45,7 @@ def source_description(self): channel = self.source_channel() if self.display_label: return self.display_label - if channel: + if channel and self.source_type == self.CHANNEL_SUBSCRIPTION_TYPE: return channel.title return self.original_url_params() diff --git a/learning_resources_search/tasks.py b/learning_resources_search/tasks.py index b3a07cf71c..7efc0057f3 100644 --- a/learning_resources_search/tasks.py +++ b/learning_resources_search/tasks.py @@ -167,7 +167,7 @@ def _infer_percolate_group(percolate_query): elif key == "offered_by": return LearningResourceOfferor.objects.get(code=val[0]).name return val[0] - return None + return percolate_query.original_url_params() def _infer_percolate_group_url(percolate_query): @@ -181,6 +181,8 @@ def _infer_percolate_group_url(percolate_query): query_string_params = {k: v for k, v in original_query.items() if v} if "endpoint" in query_string_params: query_string_params.pop("endpoint") + if "sortby" not in query_string_params: + query_string_params["sortby"] = "new" query_string = urlencode(query_string_params, doseq=True) return frontend_absolute_url(f"/search?{query_string}") @@ -886,8 +888,8 @@ def _generate_subscription_digest_subject( if sample_course["source_channel_type"] == "saved_search": return ( f"{prefix}New" - f' "{source_name}" ' - f"{unique_resource_types.pop().capitalize()}{pluralize(total_count)}" + f" {unique_resource_types.pop().capitalize()}{pluralize(total_count)}: " + f"{sample_course['resource_title']}" ) preposition = "from" if sample_course["source_channel_type"] == "topic": From e93a23abf54c377878784b136c136aa35daaec9e Mon Sep 17 00:00:00 2001 From: shankar ambady Date: Fri, 27 Sep 2024 15:28:40 -0400 Subject: [PATCH 23/48] Revert "changes for formatting search subscription emails" This reverts commit 3a906b81b24875ba53be3e143c2fdd349d15240f. --- learning_resources_search/models.py | 2 +- learning_resources_search/tasks.py | 8 +++----- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/learning_resources_search/models.py b/learning_resources_search/models.py index 56b24b5b70..8b10a69981 100644 --- a/learning_resources_search/models.py +++ b/learning_resources_search/models.py @@ -45,7 +45,7 @@ def source_description(self): channel = self.source_channel() if self.display_label: return self.display_label - if channel and self.source_type == self.CHANNEL_SUBSCRIPTION_TYPE: + if channel: return channel.title return self.original_url_params() diff --git a/learning_resources_search/tasks.py b/learning_resources_search/tasks.py index 7efc0057f3..b3a07cf71c 100644 --- a/learning_resources_search/tasks.py +++ b/learning_resources_search/tasks.py @@ -167,7 +167,7 @@ def _infer_percolate_group(percolate_query): elif key == "offered_by": return LearningResourceOfferor.objects.get(code=val[0]).name return val[0] - return percolate_query.original_url_params() + return None def _infer_percolate_group_url(percolate_query): @@ -181,8 +181,6 @@ def _infer_percolate_group_url(percolate_query): query_string_params = {k: v for k, v in original_query.items() if v} if "endpoint" in query_string_params: query_string_params.pop("endpoint") - if "sortby" not in query_string_params: - query_string_params["sortby"] = "new" query_string = urlencode(query_string_params, doseq=True) return frontend_absolute_url(f"/search?{query_string}") @@ -888,8 +886,8 @@ def _generate_subscription_digest_subject( if sample_course["source_channel_type"] == "saved_search": return ( f"{prefix}New" - f" {unique_resource_types.pop().capitalize()}{pluralize(total_count)}: " - f"{sample_course['resource_title']}" + f' "{source_name}" ' + f"{unique_resource_types.pop().capitalize()}{pluralize(total_count)}" ) preposition = "from" if sample_course["source_channel_type"] == "topic": From 8045dcba7dbdb92d40efc41b82214eafaa5fe548 Mon Sep 17 00:00:00 2001 From: James Kachel Date: Mon, 30 Sep 2024 10:16:26 -0500 Subject: [PATCH 24/48] Add additional event capturing for some interactions (#1596) Adds event capturing for facet selection and search term updating, so we can track these more efficiently in PostHog. --- .../Dialogs/AddToListDialog.tsx | 14 +++++++++ .../src/pages/SearchPage/SearchPage.tsx | 8 ++++- frontends/ol-ckeditor/src/types/settings.d.ts | 7 +++++ frontends/ol-components/package.json | 1 + .../components/SearchInput/SearchInput.tsx | 31 ++++++++++++++----- yarn.lock | 12 +++++++ 6 files changed, 64 insertions(+), 9 deletions(-) diff --git a/frontends/mit-learn/src/page-components/Dialogs/AddToListDialog.tsx b/frontends/mit-learn/src/page-components/Dialogs/AddToListDialog.tsx index 037a39031e..239b34563e 100644 --- a/frontends/mit-learn/src/page-components/Dialogs/AddToListDialog.tsx +++ b/frontends/mit-learn/src/page-components/Dialogs/AddToListDialog.tsx @@ -10,6 +10,7 @@ import { } from "ol-components" import { RiAddLine } from "@remixicon/react" +import { usePostHog } from "posthog-js/react" import * as NiceModal from "@ebay/nice-modal-react" @@ -67,6 +68,7 @@ const AddToListDialogInner: React.FC = ({ isLoading: isSavingLearningPathRelationships, mutateAsync: setLearningPathRelationships, } = useLearningResourceSetLearningPathRelationships() + const posthog = usePostHog() const isSaving = isSavingLearningPathRelationships || isSavingUserListRelationships let dialogTitle = "Add to list" @@ -93,6 +95,9 @@ const AddToListDialogInner: React.FC = ({ : null, ) .filter((value) => value !== null) + + const { POSTHOG } = APP_SETTINGS + const formik = useFormik({ enableReinitialize: true, validateOnChange: false, @@ -103,6 +108,15 @@ const AddToListDialogInner: React.FC = ({ }, onSubmit: async (values) => { if (resource) { + if (!(!POSTHOG?.api_key || POSTHOG.api_key.length < 1)) { + posthog.capture("lr_add_to_list", { + listType: listType, + resourceId: resource?.id, + readableId: resource?.readable_id, + platformCode: resource?.platform?.code, + resourceType: resource?.resource_type, + }) + } if (listType === ListType.LearningPath) { const newParents = values.learning_paths.map((id) => parseInt(id)) await setLearningPathRelationships({ diff --git a/frontends/mit-learn/src/pages/SearchPage/SearchPage.tsx b/frontends/mit-learn/src/pages/SearchPage/SearchPage.tsx index 3b8c3830cd..476945824c 100644 --- a/frontends/mit-learn/src/pages/SearchPage/SearchPage.tsx +++ b/frontends/mit-learn/src/pages/SearchPage/SearchPage.tsx @@ -20,6 +20,7 @@ import type { LearningResourceOfferor } from "api" import { useOfferorsList } from "api/hooks/learningResources" import { capitalize } from "ol-utilities" import MetaTags from "@/page-components/MetaTags/MetaTags" +import { usePostHog } from "posthog-js/react" const cssGradient = ` linear-gradient( @@ -175,6 +176,8 @@ const useFacetManifest = (resourceCategory: string | null) => { const SearchPage: React.FC = () => { const [searchParams, setSearchParams] = useSearchParams() const facetManifest = useFacetManifest(searchParams.get("resource_category")) + const posthog = usePostHog() + const { POSTHOG } = APP_SETTINGS const setPage = useCallback( (newPage: number) => { @@ -191,8 +194,11 @@ const SearchPage: React.FC = () => { [setSearchParams], ) const onFacetsChange = useCallback(() => { + if (!(!POSTHOG?.api_key || POSTHOG.api_key.length < 1)) { + posthog.capture("search_update") + } setPage(1) - }, [setPage]) + }, [setPage, posthog, POSTHOG]) const { params, diff --git a/frontends/ol-ckeditor/src/types/settings.d.ts b/frontends/ol-ckeditor/src/types/settings.d.ts index 237e6ca039..5b68d7c8b7 100644 --- a/frontends/ol-ckeditor/src/types/settings.d.ts +++ b/frontends/ol-ckeditor/src/types/settings.d.ts @@ -1,5 +1,11 @@ /* eslint-disable no-var */ +export type PostHogSettings = { + api_key: string + timeout?: int + bootstrap_flags?: Record +} + export declare global { const APP_SETTINGS: { EMBEDLY_KEY: string @@ -9,5 +15,6 @@ export declare global { PUBLIC_URL: string SITE_NAME: string CSRF_COOKIE_NAME: string + POSTHOG?: PostHogSettings } } diff --git a/frontends/ol-components/package.json b/frontends/ol-components/package.json index 8942839161..87b9df5a3c 100644 --- a/frontends/ol-components/package.json +++ b/frontends/ol-components/package.json @@ -26,6 +26,7 @@ "material-ui-popup-state": "^5.1.0", "ol-test-utilities": "0.0.0", "ol-utilities": "0.0.0", + "posthog-js": "^1.165.0", "react": "18.3.1", "react-router": "^6.22.2", "react-router-dom": "^6.22.2", diff --git a/frontends/ol-components/src/components/SearchInput/SearchInput.tsx b/frontends/ol-components/src/components/SearchInput/SearchInput.tsx index 58c6c0935a..09e711f40e 100644 --- a/frontends/ol-components/src/components/SearchInput/SearchInput.tsx +++ b/frontends/ol-components/src/components/SearchInput/SearchInput.tsx @@ -3,6 +3,7 @@ import { RiSearch2Line, RiCloseLine } from "@remixicon/react" import { Input, AdornmentButton } from "../Input/Input" import type { InputProps } from "../Input/Input" import styled from "@emotion/styled" +import { usePostHog } from "posthog-js/react" const StyledInput = styled(Input)(({ theme }) => ({ boxShadow: "0px 8px 20px 0px rgba(120, 147, 172, 0.10)", @@ -53,18 +54,32 @@ const muiInputProps = { "aria-label": "Search for" } const SearchInput: React.FC = (props) => { const { onSubmit, value } = props - const handleSubmit = useCallback(() => { - const event = { - target: { value }, - preventDefault: () => null, - } - onSubmit(event) - }, [onSubmit, value]) + const posthog = usePostHog() + const { POSTHOG } = APP_SETTINGS + + const handleSubmit = useCallback( + ( + ev: + | React.SyntheticEvent + | React.SyntheticEvent, + isEnter: boolean = false, + ) => { + const event = { + target: { value }, + preventDefault: () => null, + } + if (!(!POSTHOG?.api_key || POSTHOG.api_key.length < 1)) { + posthog.capture("search_update", { isEnter: isEnter }) + } + onSubmit(event) + }, + [onSubmit, value, posthog, POSTHOG], + ) const onInputKeyDown: React.KeyboardEventHandler = useCallback( (e) => { if (e.key !== "Enter") return - handleSubmit() + handleSubmit(e, true) }, [handleSubmit], ) diff --git a/yarn.lock b/yarn.lock index 7fa390a739..6452e2d589 100644 --- a/yarn.lock +++ b/yarn.lock @@ -15408,6 +15408,7 @@ __metadata: material-ui-popup-state: "npm:^5.1.0" ol-test-utilities: "npm:0.0.0" ol-utilities: "npm:0.0.0" + posthog-js: "npm:^1.165.0" prop-types: "npm:^15.8.1" react: "npm:18.3.1" react-router: "npm:^6.22.2" @@ -16577,6 +16578,17 @@ __metadata: languageName: node linkType: hard +"posthog-js@npm:^1.165.0": + version: 1.165.0 + resolution: "posthog-js@npm:1.165.0" + dependencies: + fflate: "npm:^0.4.8" + preact: "npm:^10.19.3" + web-vitals: "npm:^4.0.1" + checksum: 10/4a640b90af24ffb173b4d20f27aab572437c8641b1ff48ad23e98d593fa7e94e63e660a4ce967a18eaabaf5102ecaff8a258315b47d1916e79a7f1ec7ad3bc7d + languageName: node + linkType: hard + "preact@npm:^10.19.3": version: 10.23.1 resolution: "preact@npm:10.23.1" From 4863e2d00d9424bcd7c741ce36290d69069f367a Mon Sep 17 00:00:00 2001 From: Nathan Levesque Date: Mon, 30 Sep 2024 13:21:20 -0400 Subject: [PATCH 25/48] Remove health checks against opensearch (#1620) --- docker-compose.apps.yml | 6 ------ docker-compose.opensearch.cluster.apps.yml | 17 ----------------- docker-compose.opensearch.single-node.apps.yml | 9 --------- 3 files changed, 32 deletions(-) delete mode 100644 docker-compose.opensearch.cluster.apps.yml delete mode 100644 docker-compose.opensearch.single-node.apps.yml diff --git a/docker-compose.apps.yml b/docker-compose.apps.yml index 06049cfe6b..0b4ac494aa 100644 --- a/docker-compose.apps.yml +++ b/docker-compose.apps.yml @@ -8,9 +8,6 @@ services: build: context: . dockerfile: Dockerfile - extends: - file: docker-compose.opensearch.${OPENSEARCH_CLUSTER_TYPE:-single-node}.apps.yml - service: web mem_limit: 1gb cpus: 2 command: ./scripts/run-django-dev.sh @@ -50,9 +47,6 @@ services: build: context: . dockerfile: Dockerfile - extends: - file: docker-compose.opensearch.${OPENSEARCH_CLUSTER_TYPE:-single-node}.apps.yml - service: web command: > /bin/bash -c ' sleep 3; diff --git a/docker-compose.opensearch.cluster.apps.yml b/docker-compose.opensearch.cluster.apps.yml deleted file mode 100644 index cd473ffef1..0000000000 --- a/docker-compose.opensearch.cluster.apps.yml +++ /dev/null @@ -1,17 +0,0 @@ -services: - web: - depends_on: - opensearch-node-mitopen-1: - condition: service_healthy - opensearch-node-mitopen-2: - condition: service_healthy - opensearch-node-mitopen-3: - condition: service_healthy - celery: - depends_on: - opensearch-node-mitopen-1: - condition: service_healthy - opensearch-node-mitopen-2: - condition: service_healthy - opensearch-node-mitopen-3: - condition: service_healthy diff --git a/docker-compose.opensearch.single-node.apps.yml b/docker-compose.opensearch.single-node.apps.yml deleted file mode 100644 index 9a66e038f3..0000000000 --- a/docker-compose.opensearch.single-node.apps.yml +++ /dev/null @@ -1,9 +0,0 @@ -services: - web: - depends_on: - opensearch-node-mitopen-1: - condition: service_healthy - celery: - depends_on: - opensearch-node-mitopen-1: - condition: service_healthy From 763024e9e10f04c65cb0f13aaf87325fa4199930 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 30 Sep 2024 16:54:11 -0400 Subject: [PATCH 26/48] Update dependency lxml to v5 (#1554) --- fixtures/common.py | 2 +- poetry.lock | 242 +++++++++++++++++++++++++++------------------ pyproject.toml | 2 +- 3 files changed, 146 insertions(+), 100 deletions(-) diff --git a/fixtures/common.py b/fixtures/common.py index 724d6f3569..4f18431505 100644 --- a/fixtures/common.py +++ b/fixtures/common.py @@ -44,7 +44,7 @@ def warnings_as_errors(): # noqa: PT004 # Ignore deprecation warnings in third party libraries warnings.filterwarnings( "ignore", - module=".*(api_jwt|api_jws|rest_framework_jwt|astroid|celery|factory|botocore|posthog).*", + module=".*(api_jwt|api_jws|rest_framework_jwt|astroid|bs4|celery|factory|botocore|posthog).*", category=DeprecationWarning, ) yield diff --git a/poetry.lock b/poetry.lock index d3d76f0daa..d1332e0ccf 100644 --- a/poetry.lock +++ b/poetry.lock @@ -2195,111 +2195,157 @@ Werkzeug = ">=2.0.0" [[package]] name = "lxml" -version = "4.9.4" +version = "5.3.0" description = "Powerful and Pythonic XML processing library combining libxml2/libxslt with the ElementTree API." optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, != 3.4.*" -files = [ - {file = "lxml-4.9.4-cp27-cp27m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e214025e23db238805a600f1f37bf9f9a15413c7bf5f9d6ae194f84980c78722"}, - {file = "lxml-4.9.4-cp27-cp27m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:ec53a09aee61d45e7dbe7e91252ff0491b6b5fee3d85b2d45b173d8ab453efc1"}, - {file = "lxml-4.9.4-cp27-cp27m-win32.whl", hash = "sha256:7d1d6c9e74c70ddf524e3c09d9dc0522aba9370708c2cb58680ea40174800013"}, - {file = "lxml-4.9.4-cp27-cp27m-win_amd64.whl", hash = "sha256:cb53669442895763e61df5c995f0e8361b61662f26c1b04ee82899c2789c8f69"}, - {file = "lxml-4.9.4-cp27-cp27mu-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:647bfe88b1997d7ae8d45dabc7c868d8cb0c8412a6e730a7651050b8c7289cf2"}, - {file = "lxml-4.9.4-cp27-cp27mu-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:4d973729ce04784906a19108054e1fd476bc85279a403ea1a72fdb051c76fa48"}, - {file = "lxml-4.9.4-cp310-cp310-macosx_11_0_x86_64.whl", hash = "sha256:056a17eaaf3da87a05523472ae84246f87ac2f29a53306466c22e60282e54ff8"}, - {file = "lxml-4.9.4-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:aaa5c173a26960fe67daa69aa93d6d6a1cd714a6eb13802d4e4bd1d24a530644"}, - {file = "lxml-4.9.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:647459b23594f370c1c01768edaa0ba0959afc39caeeb793b43158bb9bb6a663"}, - {file = "lxml-4.9.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:bdd9abccd0927673cffe601d2c6cdad1c9321bf3437a2f507d6b037ef91ea307"}, - {file = "lxml-4.9.4-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:00e91573183ad273e242db5585b52670eddf92bacad095ce25c1e682da14ed91"}, - {file = "lxml-4.9.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:a602ed9bd2c7d85bd58592c28e101bd9ff9c718fbde06545a70945ffd5d11868"}, - {file = "lxml-4.9.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:de362ac8bc962408ad8fae28f3967ce1a262b5d63ab8cefb42662566737f1dc7"}, - {file = "lxml-4.9.4-cp310-cp310-win32.whl", hash = "sha256:33714fcf5af4ff7e70a49731a7cc8fd9ce910b9ac194f66eaa18c3cc0a4c02be"}, - {file = "lxml-4.9.4-cp310-cp310-win_amd64.whl", hash = "sha256:d3caa09e613ece43ac292fbed513a4bce170681a447d25ffcbc1b647d45a39c5"}, - {file = "lxml-4.9.4-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:359a8b09d712df27849e0bcb62c6a3404e780b274b0b7e4c39a88826d1926c28"}, - {file = "lxml-4.9.4-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:43498ea734ccdfb92e1886dfedaebeb81178a241d39a79d5351ba2b671bff2b2"}, - {file = "lxml-4.9.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:4855161013dfb2b762e02b3f4d4a21cc7c6aec13c69e3bffbf5022b3e708dd97"}, - {file = "lxml-4.9.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:c71b5b860c5215fdbaa56f715bc218e45a98477f816b46cfde4a84d25b13274e"}, - {file = "lxml-4.9.4-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:9a2b5915c333e4364367140443b59f09feae42184459b913f0f41b9fed55794a"}, - {file = "lxml-4.9.4-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:d82411dbf4d3127b6cde7da0f9373e37ad3a43e89ef374965465928f01c2b979"}, - {file = "lxml-4.9.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:273473d34462ae6e97c0f4e517bd1bf9588aa67a1d47d93f760a1282640e24ac"}, - {file = "lxml-4.9.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:389d2b2e543b27962990ab529ac6720c3dded588cc6d0f6557eec153305a3622"}, - {file = "lxml-4.9.4-cp311-cp311-win32.whl", hash = "sha256:8aecb5a7f6f7f8fe9cac0bcadd39efaca8bbf8d1bf242e9f175cbe4c925116c3"}, - {file = "lxml-4.9.4-cp311-cp311-win_amd64.whl", hash = "sha256:c7721a3ef41591341388bb2265395ce522aba52f969d33dacd822da8f018aff8"}, - {file = "lxml-4.9.4-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:dbcb2dc07308453db428a95a4d03259bd8caea97d7f0776842299f2d00c72fc8"}, - {file = "lxml-4.9.4-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:01bf1df1db327e748dcb152d17389cf6d0a8c5d533ef9bab781e9d5037619229"}, - {file = "lxml-4.9.4-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:e8f9f93a23634cfafbad6e46ad7d09e0f4a25a2400e4a64b1b7b7c0fbaa06d9d"}, - {file = "lxml-4.9.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:3f3f00a9061605725df1816f5713d10cd94636347ed651abdbc75828df302b20"}, - {file = "lxml-4.9.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:953dd5481bd6252bd480d6ec431f61d7d87fdcbbb71b0d2bdcfc6ae00bb6fb10"}, - {file = "lxml-4.9.4-cp312-cp312-win32.whl", hash = "sha256:266f655d1baff9c47b52f529b5f6bec33f66042f65f7c56adde3fcf2ed62ae8b"}, - {file = "lxml-4.9.4-cp312-cp312-win_amd64.whl", hash = "sha256:f1faee2a831fe249e1bae9cbc68d3cd8a30f7e37851deee4d7962b17c410dd56"}, - {file = "lxml-4.9.4-cp35-cp35m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:23d891e5bdc12e2e506e7d225d6aa929e0a0368c9916c1fddefab88166e98b20"}, - {file = "lxml-4.9.4-cp35-cp35m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:e96a1788f24d03e8d61679f9881a883ecdf9c445a38f9ae3f3f193ab6c591c66"}, - {file = "lxml-4.9.4-cp36-cp36m-macosx_11_0_x86_64.whl", hash = "sha256:5557461f83bb7cc718bc9ee1f7156d50e31747e5b38d79cf40f79ab1447afd2d"}, - {file = "lxml-4.9.4-cp36-cp36m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:fdb325b7fba1e2c40b9b1db407f85642e32404131c08480dd652110fc908561b"}, - {file = "lxml-4.9.4-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3d74d4a3c4b8f7a1f676cedf8e84bcc57705a6d7925e6daef7a1e54ae543a197"}, - {file = "lxml-4.9.4-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:ac7674d1638df129d9cb4503d20ffc3922bd463c865ef3cb412f2c926108e9a4"}, - {file = "lxml-4.9.4-cp36-cp36m-manylinux_2_28_x86_64.whl", hash = "sha256:ddd92e18b783aeb86ad2132d84a4b795fc5ec612e3545c1b687e7747e66e2b53"}, - {file = "lxml-4.9.4-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2bd9ac6e44f2db368ef8986f3989a4cad3de4cd55dbdda536e253000c801bcc7"}, - {file = "lxml-4.9.4-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:bc354b1393dce46026ab13075f77b30e40b61b1a53e852e99d3cc5dd1af4bc85"}, - {file = "lxml-4.9.4-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:f836f39678cb47c9541f04d8ed4545719dc31ad850bf1832d6b4171e30d65d23"}, - {file = "lxml-4.9.4-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:9c131447768ed7bc05a02553d939e7f0e807e533441901dd504e217b76307745"}, - {file = "lxml-4.9.4-cp36-cp36m-win32.whl", hash = "sha256:bafa65e3acae612a7799ada439bd202403414ebe23f52e5b17f6ffc2eb98c2be"}, - {file = "lxml-4.9.4-cp36-cp36m-win_amd64.whl", hash = "sha256:6197c3f3c0b960ad033b9b7d611db11285bb461fc6b802c1dd50d04ad715c225"}, - {file = "lxml-4.9.4-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:7b378847a09d6bd46047f5f3599cdc64fcb4cc5a5a2dd0a2af610361fbe77b16"}, - {file = "lxml-4.9.4-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:1343df4e2e6e51182aad12162b23b0a4b3fd77f17527a78c53f0f23573663545"}, - {file = "lxml-4.9.4-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:6dbdacf5752fbd78ccdb434698230c4f0f95df7dd956d5f205b5ed6911a1367c"}, - {file = "lxml-4.9.4-cp37-cp37m-manylinux_2_28_x86_64.whl", hash = "sha256:506becdf2ecaebaf7f7995f776394fcc8bd8a78022772de66677c84fb02dd33d"}, - {file = "lxml-4.9.4-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ca8e44b5ba3edb682ea4e6185b49661fc22b230cf811b9c13963c9f982d1d964"}, - {file = "lxml-4.9.4-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:9d9d5726474cbbef279fd709008f91a49c4f758bec9c062dfbba88eab00e3ff9"}, - {file = "lxml-4.9.4-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:bbdd69e20fe2943b51e2841fc1e6a3c1de460d630f65bde12452d8c97209464d"}, - {file = "lxml-4.9.4-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:8671622256a0859f5089cbe0ce4693c2af407bc053dcc99aadff7f5310b4aa02"}, - {file = "lxml-4.9.4-cp37-cp37m-win32.whl", hash = "sha256:dd4fda67f5faaef4f9ee5383435048ee3e11ad996901225ad7615bc92245bc8e"}, - {file = "lxml-4.9.4-cp37-cp37m-win_amd64.whl", hash = "sha256:6bee9c2e501d835f91460b2c904bc359f8433e96799f5c2ff20feebd9bb1e590"}, - {file = "lxml-4.9.4-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:1f10f250430a4caf84115b1e0f23f3615566ca2369d1962f82bef40dd99cd81a"}, - {file = "lxml-4.9.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:3b505f2bbff50d261176e67be24e8909e54b5d9d08b12d4946344066d66b3e43"}, - {file = "lxml-4.9.4-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:1449f9451cd53e0fd0a7ec2ff5ede4686add13ac7a7bfa6988ff6d75cff3ebe2"}, - {file = "lxml-4.9.4-cp38-cp38-manylinux_2_28_x86_64.whl", hash = "sha256:4ece9cca4cd1c8ba889bfa67eae7f21d0d1a2e715b4d5045395113361e8c533d"}, - {file = "lxml-4.9.4-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:59bb5979f9941c61e907ee571732219fa4774d5a18f3fa5ff2df963f5dfaa6bc"}, - {file = "lxml-4.9.4-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:b1980dbcaad634fe78e710c8587383e6e3f61dbe146bcbfd13a9c8ab2d7b1192"}, - {file = "lxml-4.9.4-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:9ae6c3363261021144121427b1552b29e7b59de9d6a75bf51e03bc072efb3c37"}, - {file = "lxml-4.9.4-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:bcee502c649fa6351b44bb014b98c09cb00982a475a1912a9881ca28ab4f9cd9"}, - {file = "lxml-4.9.4-cp38-cp38-win32.whl", hash = "sha256:a8edae5253efa75c2fc79a90068fe540b197d1c7ab5803b800fccfe240eed33c"}, - {file = "lxml-4.9.4-cp38-cp38-win_amd64.whl", hash = "sha256:701847a7aaefef121c5c0d855b2affa5f9bd45196ef00266724a80e439220e46"}, - {file = "lxml-4.9.4-cp39-cp39-macosx_11_0_x86_64.whl", hash = "sha256:f610d980e3fccf4394ab3806de6065682982f3d27c12d4ce3ee46a8183d64a6a"}, - {file = "lxml-4.9.4-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:aa9b5abd07f71b081a33115d9758ef6077924082055005808f68feccb27616bd"}, - {file = "lxml-4.9.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:365005e8b0718ea6d64b374423e870648ab47c3a905356ab6e5a5ff03962b9a9"}, - {file = "lxml-4.9.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:16b9ec51cc2feab009e800f2c6327338d6ee4e752c76e95a35c4465e80390ccd"}, - {file = "lxml-4.9.4-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:a905affe76f1802edcac554e3ccf68188bea16546071d7583fb1b693f9cf756b"}, - {file = "lxml-4.9.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fd814847901df6e8de13ce69b84c31fc9b3fb591224d6762d0b256d510cbf382"}, - {file = "lxml-4.9.4-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:91bbf398ac8bb7d65a5a52127407c05f75a18d7015a270fdd94bbcb04e65d573"}, - {file = "lxml-4.9.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:f99768232f036b4776ce419d3244a04fe83784bce871b16d2c2e984c7fcea847"}, - {file = "lxml-4.9.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:bb5bd6212eb0edfd1e8f254585290ea1dadc3687dd8fd5e2fd9a87c31915cdab"}, - {file = "lxml-4.9.4-cp39-cp39-win32.whl", hash = "sha256:88f7c383071981c74ec1998ba9b437659e4fd02a3c4a4d3efc16774eb108d0ec"}, - {file = "lxml-4.9.4-cp39-cp39-win_amd64.whl", hash = "sha256:936e8880cc00f839aa4173f94466a8406a96ddce814651075f95837316369899"}, - {file = "lxml-4.9.4-pp310-pypy310_pp73-macosx_11_0_x86_64.whl", hash = "sha256:f6c35b2f87c004270fa2e703b872fcc984d714d430b305145c39d53074e1ffe0"}, - {file = "lxml-4.9.4-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:606d445feeb0856c2b424405236a01c71af7c97e5fe42fbc778634faef2b47e4"}, - {file = "lxml-4.9.4-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:a1bdcbebd4e13446a14de4dd1825f1e778e099f17f79718b4aeaf2403624b0f7"}, - {file = "lxml-4.9.4-pp37-pypy37_pp73-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:0a08c89b23117049ba171bf51d2f9c5f3abf507d65d016d6e0fa2f37e18c0fc5"}, - {file = "lxml-4.9.4-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:232fd30903d3123be4c435fb5159938c6225ee8607b635a4d3fca847003134ba"}, - {file = "lxml-4.9.4-pp37-pypy37_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:231142459d32779b209aa4b4d460b175cadd604fed856f25c1571a9d78114771"}, - {file = "lxml-4.9.4-pp38-pypy38_pp73-macosx_11_0_x86_64.whl", hash = "sha256:520486f27f1d4ce9654154b4494cf9307b495527f3a2908ad4cb48e4f7ed7ef7"}, - {file = "lxml-4.9.4-pp38-pypy38_pp73-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:562778586949be7e0d7435fcb24aca4810913771f845d99145a6cee64d5b67ca"}, - {file = "lxml-4.9.4-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:a9e7c6d89c77bb2770c9491d988f26a4b161d05c8ca58f63fb1f1b6b9a74be45"}, - {file = "lxml-4.9.4-pp38-pypy38_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:786d6b57026e7e04d184313c1359ac3d68002c33e4b1042ca58c362f1d09ff58"}, - {file = "lxml-4.9.4-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:95ae6c5a196e2f239150aa4a479967351df7f44800c93e5a975ec726fef005e2"}, - {file = "lxml-4.9.4-pp39-pypy39_pp73-macosx_11_0_x86_64.whl", hash = "sha256:9b556596c49fa1232b0fff4b0e69b9d4083a502e60e404b44341e2f8fb7187f5"}, - {file = "lxml-4.9.4-pp39-pypy39_pp73-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:cc02c06e9e320869d7d1bd323df6dd4281e78ac2e7f8526835d3d48c69060683"}, - {file = "lxml-4.9.4-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:857d6565f9aa3464764c2cb6a2e3c2e75e1970e877c188f4aeae45954a314e0c"}, - {file = "lxml-4.9.4-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:c42ae7e010d7d6bc51875d768110c10e8a59494855c3d4c348b068f5fb81fdcd"}, - {file = "lxml-4.9.4-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:f10250bb190fb0742e3e1958dd5c100524c2cc5096c67c8da51233f7448dc137"}, - {file = "lxml-4.9.4.tar.gz", hash = "sha256:b1541e50b78e15fa06a2670157a1962ef06591d4c998b998047fff5e3236880e"}, +python-versions = ">=3.6" +files = [ + {file = "lxml-5.3.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:dd36439be765e2dde7660212b5275641edbc813e7b24668831a5c8ac91180656"}, + {file = "lxml-5.3.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ae5fe5c4b525aa82b8076c1a59d642c17b6e8739ecf852522c6321852178119d"}, + {file = "lxml-5.3.0-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:501d0d7e26b4d261fca8132854d845e4988097611ba2531408ec91cf3fd9d20a"}, + {file = "lxml-5.3.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fb66442c2546446944437df74379e9cf9e9db353e61301d1a0e26482f43f0dd8"}, + {file = "lxml-5.3.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9e41506fec7a7f9405b14aa2d5c8abbb4dbbd09d88f9496958b6d00cb4d45330"}, + {file = "lxml-5.3.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f7d4a670107d75dfe5ad080bed6c341d18c4442f9378c9f58e5851e86eb79965"}, + {file = "lxml-5.3.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:41ce1f1e2c7755abfc7e759dc34d7d05fd221723ff822947132dc934d122fe22"}, + {file = "lxml-5.3.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:44264ecae91b30e5633013fb66f6ddd05c006d3e0e884f75ce0b4755b3e3847b"}, + {file = "lxml-5.3.0-cp310-cp310-manylinux_2_28_ppc64le.whl", hash = "sha256:3c174dc350d3ec52deb77f2faf05c439331d6ed5e702fc247ccb4e6b62d884b7"}, + {file = "lxml-5.3.0-cp310-cp310-manylinux_2_28_s390x.whl", hash = "sha256:2dfab5fa6a28a0b60a20638dc48e6343c02ea9933e3279ccb132f555a62323d8"}, + {file = "lxml-5.3.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:b1c8c20847b9f34e98080da785bb2336ea982e7f913eed5809e5a3c872900f32"}, + {file = "lxml-5.3.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:2c86bf781b12ba417f64f3422cfc302523ac9cd1d8ae8c0f92a1c66e56ef2e86"}, + {file = "lxml-5.3.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:c162b216070f280fa7da844531169be0baf9ccb17263cf5a8bf876fcd3117fa5"}, + {file = "lxml-5.3.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:36aef61a1678cb778097b4a6eeae96a69875d51d1e8f4d4b491ab3cfb54b5a03"}, + {file = "lxml-5.3.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f65e5120863c2b266dbcc927b306c5b78e502c71edf3295dfcb9501ec96e5fc7"}, + {file = "lxml-5.3.0-cp310-cp310-win32.whl", hash = "sha256:ef0c1fe22171dd7c7c27147f2e9c3e86f8bdf473fed75f16b0c2e84a5030ce80"}, + {file = "lxml-5.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:052d99051e77a4f3e8482c65014cf6372e61b0a6f4fe9edb98503bb5364cfee3"}, + {file = "lxml-5.3.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:74bcb423462233bc5d6066e4e98b0264e7c1bed7541fff2f4e34fe6b21563c8b"}, + {file = "lxml-5.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a3d819eb6f9b8677f57f9664265d0a10dd6551d227afb4af2b9cd7bdc2ccbf18"}, + {file = "lxml-5.3.0-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5b8f5db71b28b8c404956ddf79575ea77aa8b1538e8b2ef9ec877945b3f46442"}, + {file = "lxml-5.3.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2c3406b63232fc7e9b8783ab0b765d7c59e7c59ff96759d8ef9632fca27c7ee4"}, + {file = "lxml-5.3.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2ecdd78ab768f844c7a1d4a03595038c166b609f6395e25af9b0f3f26ae1230f"}, + {file = "lxml-5.3.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:168f2dfcfdedf611eb285efac1516c8454c8c99caf271dccda8943576b67552e"}, + {file = "lxml-5.3.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aa617107a410245b8660028a7483b68e7914304a6d4882b5ff3d2d3eb5948d8c"}, + {file = "lxml-5.3.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:69959bd3167b993e6e710b99051265654133a98f20cec1d9b493b931942e9c16"}, + {file = "lxml-5.3.0-cp311-cp311-manylinux_2_28_ppc64le.whl", hash = "sha256:bd96517ef76c8654446fc3db9242d019a1bb5fe8b751ba414765d59f99210b79"}, + {file = "lxml-5.3.0-cp311-cp311-manylinux_2_28_s390x.whl", hash = "sha256:ab6dd83b970dc97c2d10bc71aa925b84788c7c05de30241b9e96f9b6d9ea3080"}, + {file = "lxml-5.3.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:eec1bb8cdbba2925bedc887bc0609a80e599c75b12d87ae42ac23fd199445654"}, + {file = "lxml-5.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6a7095eeec6f89111d03dabfe5883a1fd54da319c94e0fb104ee8f23616b572d"}, + {file = "lxml-5.3.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:6f651ebd0b21ec65dfca93aa629610a0dbc13dbc13554f19b0113da2e61a4763"}, + {file = "lxml-5.3.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:f422a209d2455c56849442ae42f25dbaaba1c6c3f501d58761c619c7836642ec"}, + {file = "lxml-5.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:62f7fdb0d1ed2065451f086519865b4c90aa19aed51081979ecd05a21eb4d1be"}, + {file = "lxml-5.3.0-cp311-cp311-win32.whl", hash = "sha256:c6379f35350b655fd817cd0d6cbeef7f265f3ae5fedb1caae2eb442bbeae9ab9"}, + {file = "lxml-5.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:9c52100e2c2dbb0649b90467935c4b0de5528833c76a35ea1a2691ec9f1ee7a1"}, + {file = "lxml-5.3.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:e99f5507401436fdcc85036a2e7dc2e28d962550afe1cbfc07c40e454256a859"}, + {file = "lxml-5.3.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:384aacddf2e5813a36495233b64cb96b1949da72bef933918ba5c84e06af8f0e"}, + {file = "lxml-5.3.0-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:874a216bf6afaf97c263b56371434e47e2c652d215788396f60477540298218f"}, + {file = "lxml-5.3.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:65ab5685d56914b9a2a34d67dd5488b83213d680b0c5d10b47f81da5a16b0b0e"}, + {file = "lxml-5.3.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aac0bbd3e8dd2d9c45ceb82249e8bdd3ac99131a32b4d35c8af3cc9db1657179"}, + {file = "lxml-5.3.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b369d3db3c22ed14c75ccd5af429086f166a19627e84a8fdade3f8f31426e52a"}, + {file = "lxml-5.3.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c24037349665434f375645fa9d1f5304800cec574d0310f618490c871fd902b3"}, + {file = "lxml-5.3.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:62d172f358f33a26d6b41b28c170c63886742f5b6772a42b59b4f0fa10526cb1"}, + {file = "lxml-5.3.0-cp312-cp312-manylinux_2_28_ppc64le.whl", hash = "sha256:c1f794c02903c2824fccce5b20c339a1a14b114e83b306ff11b597c5f71a1c8d"}, + {file = "lxml-5.3.0-cp312-cp312-manylinux_2_28_s390x.whl", hash = "sha256:5d6a6972b93c426ace71e0be9a6f4b2cfae9b1baed2eed2006076a746692288c"}, + {file = "lxml-5.3.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:3879cc6ce938ff4eb4900d901ed63555c778731a96365e53fadb36437a131a99"}, + {file = "lxml-5.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:74068c601baff6ff021c70f0935b0c7bc528baa8ea210c202e03757c68c5a4ff"}, + {file = "lxml-5.3.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:ecd4ad8453ac17bc7ba3868371bffb46f628161ad0eefbd0a855d2c8c32dd81a"}, + {file = "lxml-5.3.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:7e2f58095acc211eb9d8b5771bf04df9ff37d6b87618d1cbf85f92399c98dae8"}, + {file = "lxml-5.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e63601ad5cd8f860aa99d109889b5ac34de571c7ee902d6812d5d9ddcc77fa7d"}, + {file = "lxml-5.3.0-cp312-cp312-win32.whl", hash = "sha256:17e8d968d04a37c50ad9c456a286b525d78c4a1c15dd53aa46c1d8e06bf6fa30"}, + {file = "lxml-5.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:c1a69e58a6bb2de65902051d57fde951febad631a20a64572677a1052690482f"}, + {file = "lxml-5.3.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8c72e9563347c7395910de6a3100a4840a75a6f60e05af5e58566868d5eb2d6a"}, + {file = "lxml-5.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e92ce66cd919d18d14b3856906a61d3f6b6a8500e0794142338da644260595cd"}, + {file = "lxml-5.3.0-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1d04f064bebdfef9240478f7a779e8c5dc32b8b7b0b2fc6a62e39b928d428e51"}, + {file = "lxml-5.3.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5c2fb570d7823c2bbaf8b419ba6e5662137f8166e364a8b2b91051a1fb40ab8b"}, + {file = "lxml-5.3.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0c120f43553ec759f8de1fee2f4794452b0946773299d44c36bfe18e83caf002"}, + {file = "lxml-5.3.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:562e7494778a69086f0312ec9689f6b6ac1c6b65670ed7d0267e49f57ffa08c4"}, + {file = "lxml-5.3.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:423b121f7e6fa514ba0c7918e56955a1d4470ed35faa03e3d9f0e3baa4c7e492"}, + {file = "lxml-5.3.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:c00f323cc00576df6165cc9d21a4c21285fa6b9989c5c39830c3903dc4303ef3"}, + {file = "lxml-5.3.0-cp313-cp313-manylinux_2_28_ppc64le.whl", hash = "sha256:1fdc9fae8dd4c763e8a31e7630afef517eab9f5d5d31a278df087f307bf601f4"}, + {file = "lxml-5.3.0-cp313-cp313-manylinux_2_28_s390x.whl", hash = "sha256:658f2aa69d31e09699705949b5fc4719cbecbd4a97f9656a232e7d6c7be1a367"}, + {file = "lxml-5.3.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:1473427aff3d66a3fa2199004c3e601e6c4500ab86696edffdbc84954c72d832"}, + {file = "lxml-5.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a87de7dd873bf9a792bf1e58b1c3887b9264036629a5bf2d2e6579fe8e73edff"}, + {file = "lxml-5.3.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:0d7b36afa46c97875303a94e8f3ad932bf78bace9e18e603f2085b652422edcd"}, + {file = "lxml-5.3.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:cf120cce539453ae086eacc0130a324e7026113510efa83ab42ef3fcfccac7fb"}, + {file = "lxml-5.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:df5c7333167b9674aa8ae1d4008fa4bc17a313cc490b2cca27838bbdcc6bb15b"}, + {file = "lxml-5.3.0-cp313-cp313-win32.whl", hash = "sha256:c802e1c2ed9f0c06a65bc4ed0189d000ada8049312cfeab6ca635e39c9608957"}, + {file = "lxml-5.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:406246b96d552e0503e17a1006fd27edac678b3fcc9f1be71a2f94b4ff61528d"}, + {file = "lxml-5.3.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:8f0de2d390af441fe8b2c12626d103540b5d850d585b18fcada58d972b74a74e"}, + {file = "lxml-5.3.0-cp36-cp36m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1afe0a8c353746e610bd9031a630a95bcfb1a720684c3f2b36c4710a0a96528f"}, + {file = "lxml-5.3.0-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:56b9861a71575f5795bde89256e7467ece3d339c9b43141dbdd54544566b3b94"}, + {file = "lxml-5.3.0-cp36-cp36m-manylinux_2_28_x86_64.whl", hash = "sha256:9fb81d2824dff4f2e297a276297e9031f46d2682cafc484f49de182aa5e5df99"}, + {file = "lxml-5.3.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:2c226a06ecb8cdef28845ae976da407917542c5e6e75dcac7cc33eb04aaeb237"}, + {file = "lxml-5.3.0-cp36-cp36m-musllinux_1_2_x86_64.whl", hash = "sha256:7d3d1ca42870cdb6d0d29939630dbe48fa511c203724820fc0fd507b2fb46577"}, + {file = "lxml-5.3.0-cp36-cp36m-win32.whl", hash = "sha256:094cb601ba9f55296774c2d57ad68730daa0b13dc260e1f941b4d13678239e70"}, + {file = "lxml-5.3.0-cp36-cp36m-win_amd64.whl", hash = "sha256:eafa2c8658f4e560b098fe9fc54539f86528651f61849b22111a9b107d18910c"}, + {file = "lxml-5.3.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:cb83f8a875b3d9b458cada4f880fa498646874ba4011dc974e071a0a84a1b033"}, + {file = "lxml-5.3.0-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:25f1b69d41656b05885aa185f5fdf822cb01a586d1b32739633679699f220391"}, + {file = "lxml-5.3.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:23e0553b8055600b3bf4a00b255ec5c92e1e4aebf8c2c09334f8368e8bd174d6"}, + {file = "lxml-5.3.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9ada35dd21dc6c039259596b358caab6b13f4db4d4a7f8665764d616daf9cc1d"}, + {file = "lxml-5.3.0-cp37-cp37m-manylinux_2_28_aarch64.whl", hash = "sha256:81b4e48da4c69313192d8c8d4311e5d818b8be1afe68ee20f6385d0e96fc9512"}, + {file = "lxml-5.3.0-cp37-cp37m-manylinux_2_28_x86_64.whl", hash = "sha256:2bc9fd5ca4729af796f9f59cd8ff160fe06a474da40aca03fcc79655ddee1a8b"}, + {file = "lxml-5.3.0-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:07da23d7ee08577760f0a71d67a861019103e4812c87e2fab26b039054594cc5"}, + {file = "lxml-5.3.0-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:ea2e2f6f801696ad7de8aec061044d6c8c0dd4037608c7cab38a9a4d316bfb11"}, + {file = "lxml-5.3.0-cp37-cp37m-win32.whl", hash = "sha256:5c54afdcbb0182d06836cc3d1be921e540be3ebdf8b8a51ee3ef987537455f84"}, + {file = "lxml-5.3.0-cp37-cp37m-win_amd64.whl", hash = "sha256:f2901429da1e645ce548bf9171784c0f74f0718c3f6150ce166be39e4dd66c3e"}, + {file = "lxml-5.3.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:c56a1d43b2f9ee4786e4658c7903f05da35b923fb53c11025712562d5cc02753"}, + {file = "lxml-5.3.0-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6ee8c39582d2652dcd516d1b879451500f8db3fe3607ce45d7c5957ab2596040"}, + {file = "lxml-5.3.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fdf3a3059611f7585a78ee10399a15566356116a4288380921a4b598d807a22"}, + {file = "lxml-5.3.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:146173654d79eb1fc97498b4280c1d3e1e5d58c398fa530905c9ea50ea849b22"}, + {file = "lxml-5.3.0-cp38-cp38-manylinux_2_28_aarch64.whl", hash = "sha256:0a7056921edbdd7560746f4221dca89bb7a3fe457d3d74267995253f46343f15"}, + {file = "lxml-5.3.0-cp38-cp38-manylinux_2_28_x86_64.whl", hash = "sha256:9e4b47ac0f5e749cfc618efdf4726269441014ae1d5583e047b452a32e221920"}, + {file = "lxml-5.3.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:f914c03e6a31deb632e2daa881fe198461f4d06e57ac3d0e05bbcab8eae01945"}, + {file = "lxml-5.3.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:213261f168c5e1d9b7535a67e68b1f59f92398dd17a56d934550837143f79c42"}, + {file = "lxml-5.3.0-cp38-cp38-win32.whl", hash = "sha256:218c1b2e17a710e363855594230f44060e2025b05c80d1f0661258142b2add2e"}, + {file = "lxml-5.3.0-cp38-cp38-win_amd64.whl", hash = "sha256:315f9542011b2c4e1d280e4a20ddcca1761993dda3afc7a73b01235f8641e903"}, + {file = "lxml-5.3.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:1ffc23010330c2ab67fac02781df60998ca8fe759e8efde6f8b756a20599c5de"}, + {file = "lxml-5.3.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:2b3778cb38212f52fac9fe913017deea2fdf4eb1a4f8e4cfc6b009a13a6d3fcc"}, + {file = "lxml-5.3.0-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4b0c7a688944891086ba192e21c5229dea54382f4836a209ff8d0a660fac06be"}, + {file = "lxml-5.3.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:747a3d3e98e24597981ca0be0fd922aebd471fa99d0043a3842d00cdcad7ad6a"}, + {file = "lxml-5.3.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:86a6b24b19eaebc448dc56b87c4865527855145d851f9fc3891673ff97950540"}, + {file = "lxml-5.3.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b11a5d918a6216e521c715b02749240fb07ae5a1fefd4b7bf12f833bc8b4fe70"}, + {file = "lxml-5.3.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:68b87753c784d6acb8a25b05cb526c3406913c9d988d51f80adecc2b0775d6aa"}, + {file = "lxml-5.3.0-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:109fa6fede314cc50eed29e6e56c540075e63d922455346f11e4d7a036d2b8cf"}, + {file = "lxml-5.3.0-cp39-cp39-manylinux_2_28_ppc64le.whl", hash = "sha256:02ced472497b8362c8e902ade23e3300479f4f43e45f4105c85ef43b8db85229"}, + {file = "lxml-5.3.0-cp39-cp39-manylinux_2_28_s390x.whl", hash = "sha256:6b038cc86b285e4f9fea2ba5ee76e89f21ed1ea898e287dc277a25884f3a7dfe"}, + {file = "lxml-5.3.0-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:7437237c6a66b7ca341e868cda48be24b8701862757426852c9b3186de1da8a2"}, + {file = "lxml-5.3.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:7f41026c1d64043a36fda21d64c5026762d53a77043e73e94b71f0521939cc71"}, + {file = "lxml-5.3.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:482c2f67761868f0108b1743098640fbb2a28a8e15bf3f47ada9fa59d9fe08c3"}, + {file = "lxml-5.3.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:1483fd3358963cc5c1c9b122c80606a3a79ee0875bcac0204149fa09d6ff2727"}, + {file = "lxml-5.3.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:2dec2d1130a9cda5b904696cec33b2cfb451304ba9081eeda7f90f724097300a"}, + {file = "lxml-5.3.0-cp39-cp39-win32.whl", hash = "sha256:a0eabd0a81625049c5df745209dc7fcef6e2aea7793e5f003ba363610aa0a3ff"}, + {file = "lxml-5.3.0-cp39-cp39-win_amd64.whl", hash = "sha256:89e043f1d9d341c52bf2af6d02e6adde62e0a46e6755d5eb60dc6e4f0b8aeca2"}, + {file = "lxml-5.3.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:7b1cd427cb0d5f7393c31b7496419da594fe600e6fdc4b105a54f82405e6626c"}, + {file = "lxml-5.3.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:51806cfe0279e06ed8500ce19479d757db42a30fd509940b1701be9c86a5ff9a"}, + {file = "lxml-5.3.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ee70d08fd60c9565ba8190f41a46a54096afa0eeb8f76bd66f2c25d3b1b83005"}, + {file = "lxml-5.3.0-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:8dc2c0395bea8254d8daebc76dcf8eb3a95ec2a46fa6fae5eaccee366bfe02ce"}, + {file = "lxml-5.3.0-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:6ba0d3dcac281aad8a0e5b14c7ed6f9fa89c8612b47939fc94f80b16e2e9bc83"}, + {file = "lxml-5.3.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:6e91cf736959057f7aac7adfc83481e03615a8e8dd5758aa1d95ea69e8931dba"}, + {file = "lxml-5.3.0-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:94d6c3782907b5e40e21cadf94b13b0842ac421192f26b84c45f13f3c9d5dc27"}, + {file = "lxml-5.3.0-pp37-pypy37_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c300306673aa0f3ed5ed9372b21867690a17dba38c68c44b287437c362ce486b"}, + {file = "lxml-5.3.0-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:78d9b952e07aed35fe2e1a7ad26e929595412db48535921c5013edc8aa4a35ce"}, + {file = "lxml-5.3.0-pp37-pypy37_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:01220dca0d066d1349bd6a1726856a78f7929f3878f7e2ee83c296c69495309e"}, + {file = "lxml-5.3.0-pp37-pypy37_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:2d9b8d9177afaef80c53c0a9e30fa252ff3036fb1c6494d427c066a4ce6a282f"}, + {file = "lxml-5.3.0-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:20094fc3f21ea0a8669dc4c61ed7fa8263bd37d97d93b90f28fc613371e7a875"}, + {file = "lxml-5.3.0-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:ace2c2326a319a0bb8a8b0e5b570c764962e95818de9f259ce814ee666603f19"}, + {file = "lxml-5.3.0-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:92e67a0be1639c251d21e35fe74df6bcc40cba445c2cda7c4a967656733249e2"}, + {file = "lxml-5.3.0-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd5350b55f9fecddc51385463a4f67a5da829bc741e38cf689f38ec9023f54ab"}, + {file = "lxml-5.3.0-pp38-pypy38_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:4c1fefd7e3d00921c44dc9ca80a775af49698bbfd92ea84498e56acffd4c5469"}, + {file = "lxml-5.3.0-pp38-pypy38_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:71a8dd38fbd2f2319136d4ae855a7078c69c9a38ae06e0c17c73fd70fc6caad8"}, + {file = "lxml-5.3.0-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:97acf1e1fd66ab53dacd2c35b319d7e548380c2e9e8c54525c6e76d21b1ae3b1"}, + {file = "lxml-5.3.0-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:68934b242c51eb02907c5b81d138cb977b2129a0a75a8f8b60b01cb8586c7b21"}, + {file = "lxml-5.3.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b710bc2b8292966b23a6a0121f7a6c51d45d2347edcc75f016ac123b8054d3f2"}, + {file = "lxml-5.3.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:18feb4b93302091b1541221196a2155aa296c363fd233814fa11e181adebc52f"}, + {file = "lxml-5.3.0-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:3eb44520c4724c2e1a57c0af33a379eee41792595023f367ba3952a2d96c2aab"}, + {file = "lxml-5.3.0-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:609251a0ca4770e5a8768ff902aa02bf636339c5a93f9349b48eb1f606f7f3e9"}, + {file = "lxml-5.3.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:516f491c834eb320d6c843156440fe7fc0d50b33e44387fcec5b02f0bc118a4c"}, + {file = "lxml-5.3.0.tar.gz", hash = "sha256:4e109ca30d1edec1ac60cdbe341905dc3b8f55b16855e03a54aaf59e51ec8c6f"}, ] [package.extras] cssselect = ["cssselect (>=0.7)"] +html-clean = ["lxml-html-clean"] html5 = ["html5lib"] htmlsoup = ["BeautifulSoup4"] -source = ["Cython (==0.29.37)"] +source = ["Cython (>=3.0.11)"] [[package]] name = "markdown2" @@ -4818,4 +4864,4 @@ testing = ["coverage (>=5.0.3)", "zope.event", "zope.testing"] [metadata] lock-version = "2.0" python-versions = "3.12.6" -content-hash = "441740137b9693b13d243459b3af4655ea62e6241e93d73b93188eb5e5ff9d78" +content-hash = "b66c70450438cb21310133ee079f7c35bc86615f7b744fdfbf08337d54191969" diff --git a/pyproject.toml b/pyproject.toml index 19b9ed22b8..7971a5971d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,7 +42,7 @@ google-api-python-client = "^2.89.0" html5lib = "^1.1" ipython = "^8.14.0" jedi = "^0.19.0" -lxml = "^4.9.2" +lxml = "^5.0.0" markdown2 = "^2.4.8" nested-lookup = "^0.2.25" ocw-data-parser = "^0.35.1" From ebb63e3ca64b1f099ffebc5fb35e450a023b1747 Mon Sep 17 00:00:00 2001 From: Nathan Levesque Date: Mon, 30 Sep 2024 16:55:02 -0400 Subject: [PATCH 27/48] Switch to using full name (#1621) --- frontends/api/src/generated/v0/api.ts | 16 ++------- .../page-components/Header/Header.test.tsx | 33 ++++--------------- .../src/page-components/Header/UserMenu.tsx | 10 +----- .../pages/DashboardPage/Dashboard.test.tsx | 11 ++++--- .../src/pages/DashboardPage/DashboardPage.tsx | 2 +- .../pages/DashboardPage/ProfileEditForm.tsx | 13 ++------ openapi/specs/v0.yaml | 10 ++---- profiles/serializers.py | 7 ++++ profiles/serializers_test.py | 4 +-- 9 files changed, 32 insertions(+), 74 deletions(-) diff --git a/frontends/api/src/generated/v0/api.ts b/frontends/api/src/generated/v0/api.ts index a8ecc603ce..694b9b5086 100644 --- a/frontends/api/src/generated/v0/api.ts +++ b/frontends/api/src/generated/v0/api.ts @@ -1597,12 +1597,6 @@ export interface PatchedChannelWriteRequest { * @interface PatchedProfileRequest */ export interface PatchedProfileRequest { - /** - * - * @type {string} - * @memberof PatchedProfileRequest - */ - name?: string | null /** * * @type {string} @@ -1928,11 +1922,11 @@ export interface PreferencesSearch { */ export interface Profile { /** - * + * Get the user\'s name * @type {string} * @memberof Profile */ - name?: string | null + name: string /** * * @type {string} @@ -2054,12 +2048,6 @@ export interface Profile { * @interface ProfileRequest */ export interface ProfileRequest { - /** - * - * @type {string} - * @memberof ProfileRequest - */ - name?: string | null /** * * @type {string} diff --git a/frontends/mit-learn/src/page-components/Header/Header.test.tsx b/frontends/mit-learn/src/page-components/Header/Header.test.tsx index 39c6962901..f6427bbdd5 100644 --- a/frontends/mit-learn/src/page-components/Header/Header.test.tsx +++ b/frontends/mit-learn/src/page-components/Header/Header.test.tsx @@ -31,10 +31,7 @@ describe("UserMenu", () => { return screen.findByRole("menu") } - test.each([ - { first_name: "", last_name: "" }, - { first_name: null, last_name: null }, - ])( + test.each([{}, { profile: null }, { profile: {} }])( "Trigger button shows UserIcon for authenticated users w/o initials", async (userSettings) => { setMockResponse.get(urls.userMe.get(), userSettings) @@ -46,29 +43,13 @@ describe("UserMenu", () => { }, ) - test.each([ - { - userSettings: { first_name: "Alice", last_name: "Bee" }, - expectedName: "Alice Bee", - }, - { - userSettings: { first_name: "Alice", last_name: "" }, - expectedName: "Alice", - }, - { - userSettings: { first_name: "", last_name: "Bee" }, - expectedName: "Bee", - }, - ])( - "Trigger button shows name if available", - async ({ userSettings, expectedName }) => { - setMockResponse.get(urls.userMe.get(), userSettings) + test("Trigger button shows name if available", async () => { + setMockResponse.get(urls.userMe.get(), { profile: { name: "Alice Bee" } }) - renderWithProviders(
) - const trigger = await screen.findByRole("button", { name: "User Menu" }) - expect(trigger.textContent).toBe(expectedName) - }, - ) + renderWithProviders(
) + const trigger = await screen.findByRole("button", { name: "User Menu" }) + expect(trigger.textContent).toBe("Alice Bee") + }) test("Unauthenticated users see the Sign Up / Login link", async () => { const isAuthenticated = false diff --git a/frontends/mit-learn/src/page-components/Header/UserMenu.tsx b/frontends/mit-learn/src/page-components/Header/UserMenu.tsx index 22c6304fc3..a90add21c0 100644 --- a/frontends/mit-learn/src/page-components/Header/UserMenu.tsx +++ b/frontends/mit-learn/src/page-components/Header/UserMenu.tsx @@ -70,15 +70,7 @@ const UserNameContainer = styled.span(({ theme }) => ({ })) const UserName: React.FC<{ user: User | undefined }> = ({ user }) => { - const first = user?.first_name ?? "" - const last = user?.last_name ?? "" - return ( - - {first} - {first && last ? " " : ""} - {last} - - ) + return {user?.profile?.name ?? ""} } const UserMenuChevron: React.FC<{ open: boolean }> = ({ open }) => { diff --git a/frontends/mit-learn/src/pages/DashboardPage/Dashboard.test.tsx b/frontends/mit-learn/src/pages/DashboardPage/Dashboard.test.tsx index d1c1efdbe6..2833ec9e0e 100644 --- a/frontends/mit-learn/src/pages/DashboardPage/Dashboard.test.tsx +++ b/frontends/mit-learn/src/pages/DashboardPage/Dashboard.test.tsx @@ -210,17 +210,20 @@ describe("DashboardPage", () => { setupAPIs() setMockResponse.get(urls.userMe.get(), { [Permissions.Authenticated]: true, - first_name: "User", - last_name: "Info", + first_name: "Joe", + last_name: "Smith", + profile: { + name: "Jane Smith", + }, }) renderWithProviders() await waitFor(() => { /** - * There should be two instances of "User Info" text, + * There should be two instances of "Jane Smith" text, * one in the header and one in the main content */ - const userInfoText = screen.getByText("User Info") + const userInfoText = screen.getByText("Jane Smith") expect(userInfoText).toBeInTheDocument() }) }) diff --git a/frontends/mit-learn/src/pages/DashboardPage/DashboardPage.tsx b/frontends/mit-learn/src/pages/DashboardPage/DashboardPage.tsx index 7b0073d72a..96310366cc 100644 --- a/frontends/mit-learn/src/pages/DashboardPage/DashboardPage.tsx +++ b/frontends/mit-learn/src/pages/DashboardPage/DashboardPage.tsx @@ -330,7 +330,7 @@ const DashboardPage: React.FC = () => { {isLoadingUser ? ( ) : ( - {`${user?.first_name} ${user?.last_name}`} + {`${user?.profile?.name}`} )} diff --git a/frontends/mit-learn/src/pages/DashboardPage/ProfileEditForm.tsx b/frontends/mit-learn/src/pages/DashboardPage/ProfileEditForm.tsx index eefdbe2f66..b7e89a58d9 100644 --- a/frontends/mit-learn/src/pages/DashboardPage/ProfileEditForm.tsx +++ b/frontends/mit-learn/src/pages/DashboardPage/ProfileEditForm.tsx @@ -103,17 +103,10 @@ const ProfileEditForm: React.FC = ({ profile }) => { - diff --git a/openapi/specs/v0.yaml b/openapi/specs/v0.yaml index 5bde16164e..98ff99fe1f 100644 --- a/openapi/specs/v0.yaml +++ b/openapi/specs/v0.yaml @@ -1893,9 +1893,6 @@ components: type: object description: Serializer for Profile properties: - name: - type: string - nullable: true image: type: string nullable: true @@ -2096,7 +2093,8 @@ components: properties: name: type: string - nullable: true + description: Get the user's name + readOnly: true image: type: string nullable: true @@ -2179,6 +2177,7 @@ components: - image_file - image_medium_file - image_small_file + - name - placename - preference_search_filters - profile_image_medium @@ -2188,9 +2187,6 @@ components: type: object description: Serializer for Profile properties: - name: - type: string - nullable: true image: type: string nullable: true diff --git a/profiles/serializers.py b/profiles/serializers.py index 27315bd0a6..2e0e1b7672 100644 --- a/profiles/serializers.py +++ b/profiles/serializers.py @@ -81,6 +81,7 @@ class PreferencesSearchSerializer(serializers.Serializer): class ProfileSerializer(serializers.ModelSerializer): """Serializer for Profile""" + name = serializers.SerializerMethodField(read_only=True) email_optin = serializers.BooleanField(write_only=True, required=False) toc_optin = serializers.BooleanField(write_only=True, required=False) username = serializers.SerializerMethodField(read_only=True) @@ -90,6 +91,12 @@ class ProfileSerializer(serializers.ModelSerializer): topic_interests = TopicInterestsField(default=list) preference_search_filters = serializers.SerializerMethodField(read_only=True) + def get_name(self, obj) -> str: + """Get the user's name""" + return obj.name or " ".join( + filter(lambda name: name, [obj.user.first_name, obj.user.last_name]) + ) + def get_username(self, obj) -> str: """Custom getter for the username""" # noqa: D401 return str(obj.user.username) diff --git a/profiles/serializers_test.py b/profiles/serializers_test.py index bcd433640d..1393b1b2fa 100644 --- a/profiles/serializers_test.py +++ b/profiles/serializers_test.py @@ -50,7 +50,6 @@ def test_serialize_create_user(db, mocker): Test creating a user """ profile = { - "name": "name", "email_optin": True, "toc_optin": True, "bio": "bio", @@ -67,6 +66,7 @@ def test_serialize_create_user(db, mocker): profile.update( { + "name": "", "image": None, "image_small": None, "image_medium": None, @@ -100,7 +100,6 @@ def test_serialize_create_user(db, mocker): @pytest.mark.parametrize( ("key", "value"), [ - ("name", "name_value"), ("email_optin", True), ("email_optin", False), ("bio", "bio_value"), @@ -191,7 +190,6 @@ def test_location_validation(user, data, is_valid): @pytest.mark.parametrize( ("key", "value"), [ - ("name", "name_value"), ("bio", "bio_value"), ("headline", "headline_value"), ("location", {"value": "Hobbiton, The Shire, Middle-Earth"}), From 1ede734957dee4335de5c6059cdfa9ba8793c6d4 Mon Sep 17 00:00:00 2001 From: Carey P Gumaer Date: Tue, 1 Oct 2024 10:26:50 -0400 Subject: [PATCH 28/48] updated header (#1622) * update logged out view * Clean up MIT logo links * use Link component * search icon and nav menu button opacity on hover * user menu style * get rid of text based mit learn logo and replace with SVG * fix vertical alignment * adjust minimum height of mui toolbar to fit design, adjust search icon hitbox * fix height of usermenu container * fix nav drawer top padding * fix header shadow * remove manual width of user menu popover and anchor it to the left side of the trigger * add hover underline to user menu popover links * mobile styling fixes * fix footer tests --- .../public/images/mit-learn-logo.svg | 11 +- ..._std_cmyk_black.svg => mit-logo-black.svg} | 0 ...ogo-transparent.svg => mit-logo-color.svg} | 0 .../public/images/mit-logo-learn-white.svg | 18 +++ .../public/images/mit-logo-transparent2.svg | 17 -- .../public/images/mit-logo-transparent3.svg | 17 -- .../public/images/mit-logo-transparent4.svg | 35 ----- .../public/images/mit-logo-transparent5.jpg | Bin 8081 -> 0 bytes .../public/images/mit-logo-transparent5.png | Bin 564 -> 0 bytes .../public/images/mit-logo-transparent5.svg | 17 -- .../public/images/mit-logo-white.svg | 14 ++ .../page-components/Footer/Footer.test.tsx | 13 +- .../src/page-components/Footer/Footer.tsx | 7 +- .../src/page-components/Header/Header.tsx | 81 ++++++---- .../src/page-components/Header/MenuButton.tsx | 17 +- .../src/page-components/Header/UserMenu.tsx | 146 ++++++++++++++---- .../MITLogoLink/MITLogoLink.tsx | 36 +++++ .../src/components/Banner/Banner.stories.tsx | 4 +- .../src/components/NavDrawer/NavDrawer.tsx | 2 +- .../src/components/ThemeProvider/colors.ts | 1 + frontends/ol-components/src/types/theme.d.ts | 1 + .../src/components/MITLogoLink.tsx | 26 ---- .../ol-utilities/src/components/index.ts | 1 - frontends/ol-utilities/src/index.ts | 1 - 24 files changed, 263 insertions(+), 202 deletions(-) rename frontends/mit-learn/public/images/{mit_logo_std_cmyk_black.svg => mit-logo-black.svg} (100%) rename frontends/mit-learn/public/images/{mit-logo-transparent.svg => mit-logo-color.svg} (100%) create mode 100644 frontends/mit-learn/public/images/mit-logo-learn-white.svg delete mode 100644 frontends/mit-learn/public/images/mit-logo-transparent2.svg delete mode 100644 frontends/mit-learn/public/images/mit-logo-transparent3.svg delete mode 100644 frontends/mit-learn/public/images/mit-logo-transparent4.svg delete mode 100644 frontends/mit-learn/public/images/mit-logo-transparent5.jpg delete mode 100644 frontends/mit-learn/public/images/mit-logo-transparent5.png delete mode 100644 frontends/mit-learn/public/images/mit-logo-transparent5.svg create mode 100644 frontends/mit-learn/public/images/mit-logo-white.svg create mode 100644 frontends/mit-learn/src/page-components/MITLogoLink/MITLogoLink.tsx delete mode 100644 frontends/ol-utilities/src/components/MITLogoLink.tsx delete mode 100644 frontends/ol-utilities/src/components/index.ts diff --git a/frontends/mit-learn/public/images/mit-learn-logo.svg b/frontends/mit-learn/public/images/mit-learn-logo.svg index 548d70485f..0069f62884 100644 --- a/frontends/mit-learn/public/images/mit-learn-logo.svg +++ b/frontends/mit-learn/public/images/mit-learn-logo.svg @@ -1,8 +1,5 @@ - - - - - - - + + diff --git a/frontends/mit-learn/public/images/mit_logo_std_cmyk_black.svg b/frontends/mit-learn/public/images/mit-logo-black.svg similarity index 100% rename from frontends/mit-learn/public/images/mit_logo_std_cmyk_black.svg rename to frontends/mit-learn/public/images/mit-logo-black.svg diff --git a/frontends/mit-learn/public/images/mit-logo-transparent.svg b/frontends/mit-learn/public/images/mit-logo-color.svg similarity index 100% rename from frontends/mit-learn/public/images/mit-logo-transparent.svg rename to frontends/mit-learn/public/images/mit-logo-color.svg diff --git a/frontends/mit-learn/public/images/mit-logo-learn-white.svg b/frontends/mit-learn/public/images/mit-logo-learn-white.svg new file mode 100644 index 0000000000..c46990cc9a --- /dev/null +++ b/frontends/mit-learn/public/images/mit-logo-learn-white.svg @@ -0,0 +1,18 @@ + + + + + + + + diff --git a/frontends/mit-learn/public/images/mit-logo-transparent2.svg b/frontends/mit-learn/public/images/mit-logo-transparent2.svg deleted file mode 100644 index 67da18b013..0000000000 --- a/frontends/mit-learn/public/images/mit-logo-transparent2.svg +++ /dev/null @@ -1,17 +0,0 @@ - - - - -logo2 - - - - - - - diff --git a/frontends/mit-learn/public/images/mit-logo-transparent3.svg b/frontends/mit-learn/public/images/mit-logo-transparent3.svg deleted file mode 100644 index 67da18b013..0000000000 --- a/frontends/mit-learn/public/images/mit-logo-transparent3.svg +++ /dev/null @@ -1,17 +0,0 @@ - - - - -logo2 - - - - - - - diff --git a/frontends/mit-learn/public/images/mit-logo-transparent4.svg b/frontends/mit-learn/public/images/mit-logo-transparent4.svg deleted file mode 100644 index 7fd59c879c..0000000000 --- a/frontends/mit-learn/public/images/mit-logo-transparent4.svg +++ /dev/null @@ -1,35 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - diff --git a/frontends/mit-learn/public/images/mit-logo-transparent5.jpg b/frontends/mit-learn/public/images/mit-logo-transparent5.jpg deleted file mode 100644 index fca614d62b24449e712425c50d04dac2f0a10d13..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8081 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 diff --git a/frontends/mit-learn/public/images/mit-logo-transparent5.png b/frontends/mit-learn/public/images/mit-logo-transparent5.png deleted file mode 100644 index 91a9f6ab32f36ade8737aba2baa5f09b60e71d3b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 564 zcmeAS@N?(olHy`uVBq!ia0y~yVAKV&=W#Ft$pDcxn}JkpfKP}kkfss%|NsBaokov< zrpuND`2{mDF|+Us2nq?yD=MoR7@C@yJ2<(zdw2y#KmGROfQYaXP$6TIx4Vm65to8C zkR9Xc;uunK>+MZ - - - -logo2 - - - - - - - diff --git a/frontends/mit-learn/public/images/mit-logo-white.svg b/frontends/mit-learn/public/images/mit-logo-white.svg new file mode 100644 index 0000000000..5b0774426b --- /dev/null +++ b/frontends/mit-learn/public/images/mit-logo-white.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + diff --git a/frontends/mit-learn/src/page-components/Footer/Footer.test.tsx b/frontends/mit-learn/src/page-components/Footer/Footer.test.tsx index 09f2b25324..0f9ee8d05f 100644 --- a/frontends/mit-learn/src/page-components/Footer/Footer.test.tsx +++ b/frontends/mit-learn/src/page-components/Footer/Footer.test.tsx @@ -3,18 +3,23 @@ import Footer from "./Footer" import React from "react" import { ThemeProvider } from "ol-components" import * as urls from "@/common/urls" +import { MemoryRouter } from "react-router" describe("Footer", () => { test("Renders the appropriate text and links", async () => { - render(
, { - wrapper: ThemeProvider, - }) + render( + + +
+
+
, + ) interface Links { [key: string]: string } const expectedLinks: Links = { // key is blank here because the link is an image - "": "https://mit.edu/", + "": "https://www.mit.edu/", Home: urls.HOME, "About Us": urls.ABOUT, Accessibility: urls.ACCESSIBILITY, diff --git a/frontends/mit-learn/src/page-components/Footer/Footer.tsx b/frontends/mit-learn/src/page-components/Footer/Footer.tsx index bcabe9704d..d399ade81a 100644 --- a/frontends/mit-learn/src/page-components/Footer/Footer.tsx +++ b/frontends/mit-learn/src/page-components/Footer/Footer.tsx @@ -1,7 +1,7 @@ import { Container, styled } from "ol-components" -import { MITLogoLink } from "ol-utilities" import * as urls from "@/common/urls" import React, { FunctionComponent } from "react" +import MITLogoLink from "../MITLogoLink/MITLogoLink" const PUBLIC_URL = APP_SETTINGS.PUBLIC_URL const HOME_URL = `${PUBLIC_URL}/` @@ -147,10 +147,7 @@ const Footer: FunctionComponent = () => { - + Massachusetts Institute of Technology

diff --git a/frontends/mit-learn/src/page-components/Header/Header.tsx b/frontends/mit-learn/src/page-components/Header/Header.tsx index a0756c3d86..64a6d81fa5 100644 --- a/frontends/mit-learn/src/page-components/Header/Header.tsx +++ b/frontends/mit-learn/src/page-components/Header/Header.tsx @@ -3,7 +3,6 @@ import type { NavData } from "ol-components" import { styled, AppBar, - Divider, NavDrawer, Toolbar, ClickAwayListener, @@ -23,7 +22,7 @@ import { RiPriceTag3Line, RiAwardLine, } from "@remixicon/react" -import { MITLogoLink, useToggle } from "ol-utilities" +import { useToggle } from "ol-utilities" import UserMenu from "./UserMenu" import { MenuButton } from "./MenuButton" import { @@ -41,18 +40,24 @@ import { SEARCH_LEARNING_MATERIAL, } from "@/common/urls" import { useUserMe } from "api/hooks/user" +import MITLogoLink from "../MITLogoLink/MITLogoLink" const Bar = styled(AppBar)(({ theme }) => ({ - height: "60px", - padding: "0 8px", - borderBottom: `1px solid ${theme.custom.colors.lightGray2}`, - backgroundColor: theme.custom.colors.white, - color: theme.custom.colors.darkGray1, + padding: "16px 8px", + borderBottom: `4px solid ${theme.custom.colors.darkGray2}`, + backgroundColor: theme.custom.colors.navGray, display: "flex", + justifyContent: "space-between", flexDirection: "column", - boxShadow: "0 2px 10px rgba(120 169 197 / 15%)", + boxShadow: "0px 3px 35px 0px rgba(23, 30, 42, 0.50)", + ".MuiToolbar-root": { + minHeight: "auto", + }, [theme.breakpoints.down("sm")]: { + height: "60px", padding: "0", + borderBottom: `1px solid ${theme.custom.colors.darkGray2}`, + boxShadow: "0px -2px 20px 0px rgba(0, 0, 0, 0.05)", }, })) @@ -83,45 +88,54 @@ const StyledToolbar = styled(Toolbar)({ flex: 1, }) -const LogoLink = styled(MITLogoLink)(({ theme }) => ({ - display: "flex", - border: "none", +const StyledMITLogoLink = styled(MITLogoLink)(({ theme }) => ({ img: { - width: 109, - height: 40, + height: "24px", [theme.breakpoints.down("sm")]: { - marginLeft: "16px", + height: "16px", }, }, })) -const LeftDivider = styled(Divider)({ - margin: "0 24px", - height: "24px", - alignSelf: "auto", +const Spacer = styled.div({ + flex: "1", }) -const RightDivider = styled(Divider)(({ theme }) => ({ - margin: "0 32px", - height: "24px", - alignSelf: "auto", +const LeftSpacer = styled.div(({ theme }) => ({ + width: "24px", [theme.breakpoints.down("sm")]: { - margin: "0 16px", + width: "16px", }, })) -const Spacer = styled.div` - flex: 1; -` +const StyledSearchButton = styled(ActionButtonLink)(({ theme }) => ({ + width: "auto", + height: "auto", + padding: "4px 16px", + "&:hover": { + svg: { + opacity: 1, + }, + }, + [theme.breakpoints.down("sm")]: { + padding: "0", + }, +})) const StyledSearchIcon = styled(RiSearch2Line)(({ theme }) => ({ - color: theme.custom.colors.darkGray2, + width: "24px", + height: "24px", + color: theme.custom.colors.white, + opacity: 0.5, margin: "4px 0", + [theme.breakpoints.down("sm")]: { + opacity: 1, + }, })) const SearchButton: FunctionComponent = () => { return ( - { aria-label="Search" > - + ) } @@ -142,7 +156,6 @@ const LoggedOutView: FunctionComponent = () => { - @@ -153,7 +166,6 @@ const LoggedInView: FunctionComponent = () => { return ( - ) @@ -265,8 +277,8 @@ const Header: FunctionComponent = () => { - - + + { - + + diff --git a/frontends/mit-learn/src/page-components/Header/MenuButton.tsx b/frontends/mit-learn/src/page-components/Header/MenuButton.tsx index 99218234c8..add3d730a7 100644 --- a/frontends/mit-learn/src/page-components/Header/MenuButton.tsx +++ b/frontends/mit-learn/src/page-components/Header/MenuButton.tsx @@ -12,7 +12,6 @@ const CloseMenuIcon = styled(RiCloseLargeLine)(({ theme }) => ({ const MenuButtonText = styled.div(({ theme }) => ({ alignSelf: "center", - color: theme.custom.colors.darkGray2, paddingLeft: "16px", textTransform: "none", [theme.breakpoints.down("sm")]: { @@ -23,12 +22,11 @@ const MenuButtonText = styled.div(({ theme }) => ({ const MenuButtonInner = styled.div({ display: "flex", - padding: "8px 0", alignItems: "flex-start", }) const StyledMenuButton = styled.button(({ theme }) => ({ - padding: "0", + padding: "8px 16px", background: "transparent", "&:hover:not(:disabled)": { background: "transparent", @@ -38,10 +36,21 @@ const StyledMenuButton = styled.button(({ theme }) => ({ display: "inline-flex", justifyContent: "center", alignItems: "center", - color: theme.palette.text.primary, + color: theme.custom.colors.white, transition: `background ${theme.transitions.duration.short}ms`, cursor: "pointer", borderStyle: "none", + opacity: 0.5, + svg: { + color: theme.custom.colors.white, + }, + [theme.breakpoints.down("sm")]: { + padding: "4px 0", + opacity: 1, + }, + "&:hover": { + opacity: 1, + }, })) interface MenuButtonProps { diff --git a/frontends/mit-learn/src/page-components/Header/UserMenu.tsx b/frontends/mit-learn/src/page-components/Header/UserMenu.tsx index a90add21c0..6a54459cd6 100644 --- a/frontends/mit-learn/src/page-components/Header/UserMenu.tsx +++ b/frontends/mit-learn/src/page-components/Header/UserMenu.tsx @@ -1,5 +1,11 @@ import React, { useState } from "react" -import { ActionButtonLink, ButtonLink, SimpleMenu, styled } from "ol-components" +import { + ActionButtonLink, + ButtonLink, + SimpleMenu, + styled, + theme, +} from "ol-components" import type { MenuOverrideProps, SimpleMenuItem } from "ol-components" import * as urls from "@/common/urls" import { @@ -9,25 +15,40 @@ import { } from "@remixicon/react" import { useUserMe, User } from "api/hooks/user" import { useLocation } from "react-router" +import MITLogoLink from "../MITLogoLink/MITLogoLink" const FlexContainer = styled.div({ display: "flex", alignItems: "center", }) -const UserMenuContainer = styled.button({ +const UserMenuContainer = styled.button(({ theme }) => ({ display: "flex", alignItems: "center", cursor: "pointer", background: "none", - color: "inherit", - border: "none", - padding: "0", + color: theme.custom.colors.white, + height: "40px", + border: `1px solid ${theme.custom.colors.silverGrayDark}`, + borderRadius: "4px", + padding: "2px 8px", + gap: "8px", font: "inherit", -}) + margin: "0 16px", + opacity: 0.75, + "&:hover": { + opacity: 1, + }, + [theme.breakpoints.down("sm")]: { + border: "none", + opacity: 1, + gap: "2px", + padding: "4px 0", + margin: "0px 24px", + }, +})) const LoginButtonContainer = styled(FlexContainer)(({ theme }) => ({ - paddingLeft: "24px", "&:hover": { textDecoration: "none", }, @@ -50,10 +71,20 @@ const LoginButtonContainer = styled(FlexContainer)(({ theme }) => ({ }, })) -const UserIcon = styled(RiAccountCircleFill)(({ theme }) => ({ +const DesktopLoginButton = styled(ButtonLink)({ + height: "40px", + padding: "18px 12px", + margin: "0 16px", +}) + +const MobileLoginButton = styled(ActionButtonLink)({ width: "24px", height: "24px", - color: theme.custom.colors.black, + margin: "0 24px", +}) + +const UserIcon = styled(RiAccountCircleFill)(({ theme }) => ({ + color: theme.custom.colors.white, })) type UserMenuItem = SimpleMenuItem & { @@ -61,8 +92,7 @@ type UserMenuItem = SimpleMenuItem & { } const UserNameContainer = styled.span(({ theme }) => ({ - color: theme.custom.colors.darkGray2, - padding: "0 12px", + color: theme.custom.colors.white, [theme.breakpoints.down("sm")]: { display: "none", }, @@ -77,11 +107,32 @@ const UserMenuChevron: React.FC<{ open: boolean }> = ({ open }) => { return open ? : } +const StyledMITLogoLink = styled(MITLogoLink)(({ theme }) => ({ + width: "64px", + height: "32px", + marginLeft: "16px", + [theme.breakpoints.down("sm")]: { + width: "48px", + height: "24px", + marginLeft: "0", + }, +})) + type DeviceType = "mobile" | "desktop" type UserMenuProps = { variant?: DeviceType } +const MITHomeLink: React.FC = () => { + return ( + + ) +} + const UserMenu: React.FC = ({ variant }) => { const [visible, setVisible] = useState(false) const location = useLocation() @@ -95,6 +146,12 @@ const UserMenu: React.FC = ({ variant }) => { }) const items: UserMenuItem[] = [ + { + label: "Home", + key: "home", + allow: true, + href: urls.HOME, + }, { label: "Dashboard", key: "dashboard", @@ -117,47 +174,73 @@ const UserMenu: React.FC = ({ variant }) => { ] const menuOverrideProps: MenuOverrideProps = { - anchorOrigin: { horizontal: "right", vertical: "bottom" }, - transformOrigin: { horizontal: "right", vertical: "top" }, + anchorOrigin: { horizontal: "left", vertical: "bottom" }, + transformOrigin: { horizontal: "left", vertical: "top" }, + slotProps: { + paper: { + sx: { + borderRadius: "0px 0px 5px 5px", + backgroundColor: theme.custom.colors.darkGray1, + padding: "0 16px", + ".MuiMenu-list": { + padding: "8px 0", + ".MuiMenuItem-root": { + backgroundColor: theme.custom.colors.darkGray1, + color: theme.custom.colors.white, + padding: "8px 0", + "&:hover": { + textDecoration: "underline", + }, + }, + }, + ...theme.typography.body2, + }, + }, + }, } if (user?.is_authenticated) { return ( - allow) - .map(({ allow, ...item }) => item)} - trigger={ - - - - {user?.is_authenticated ? : ""} - - } - /> + <> + allow) + .map(({ allow, ...item }) => item)} + trigger={ + + + + {user?.is_authenticated ? : ""} + + } + /> + + ) } else { return ( {variant === "desktop" ? ( - Log In - + + ) : ( "" )} {variant === "mobile" ? ( - = ({ variant }) => { aria-label="Log in" > - + + ) : ( "" diff --git a/frontends/mit-learn/src/page-components/MITLogoLink/MITLogoLink.tsx b/frontends/mit-learn/src/page-components/MITLogoLink/MITLogoLink.tsx new file mode 100644 index 0000000000..45c4e872c0 --- /dev/null +++ b/frontends/mit-learn/src/page-components/MITLogoLink/MITLogoLink.tsx @@ -0,0 +1,36 @@ +import { Link, styled } from "ol-components" +import React from "react" + +const PUBLIC_URL = APP_SETTINGS.PUBLIC_URL +const MIT_LEARN_HOME_URL = `${PUBLIC_URL}/` + +const MIT_LOGO_URL = `${PUBLIC_URL}/static/images/mit-logo-black.svg` + +const StyledLink = styled(Link)({ + display: "flex", + alignItems: "center", + justifyContent: "center", +}) + +interface Props { + href?: string + src?: string + className?: string + target?: string +} + +const MITLogoLink: React.FC = ({ href, src, className, target }) => ( + + MIT Logo + +) + +export default MITLogoLink diff --git a/frontends/ol-components/src/components/Banner/Banner.stories.tsx b/frontends/ol-components/src/components/Banner/Banner.stories.tsx index 77521fdd20..e56690ae39 100644 --- a/frontends/ol-components/src/components/Banner/Banner.stories.tsx +++ b/frontends/ol-components/src/components/Banner/Banner.stories.tsx @@ -48,7 +48,7 @@ export const logoBanner: Story = { @@ -66,7 +66,7 @@ export const logoBannerWithExtras: Story = { diff --git a/frontends/ol-components/src/components/NavDrawer/NavDrawer.tsx b/frontends/ol-components/src/components/NavDrawer/NavDrawer.tsx index 95d711dd49..1f5208bea3 100644 --- a/frontends/ol-components/src/components/NavDrawer/NavDrawer.tsx +++ b/frontends/ol-components/src/components/NavDrawer/NavDrawer.tsx @@ -3,7 +3,7 @@ import styled from "@emotion/styled" import React, { ReactElement } from "react" const DrawerContent = styled.div(({ theme }) => ({ - paddingTop: "60px", + paddingTop: "72px", width: "366px", height: "100%", background: theme.custom.colors.white, diff --git a/frontends/ol-components/src/components/ThemeProvider/colors.ts b/frontends/ol-components/src/components/ThemeProvider/colors.ts index 47b876a8a3..0c0ae91037 100644 --- a/frontends/ol-components/src/components/ThemeProvider/colors.ts +++ b/frontends/ol-components/src/components/ThemeProvider/colors.ts @@ -10,6 +10,7 @@ const colors = { silverGrayLight: "#B8C2CC", lightGray2: "#DDE1E6", lightGray1: "#F3F4F8", + navGray: "#303337", darkPink: "#750062", pink: "#FF14F0", lightPink: "#FFB3FF", diff --git a/frontends/ol-components/src/types/theme.d.ts b/frontends/ol-components/src/types/theme.d.ts index e971ffe9af..21986bf478 100644 --- a/frontends/ol-components/src/types/theme.d.ts +++ b/frontends/ol-components/src/types/theme.d.ts @@ -24,6 +24,7 @@ interface CustomTheme { silverGrayLight: string lightGray2: string lightGray1: string + navGray: string darkPink: string pink: string lightPink: string diff --git a/frontends/ol-utilities/src/components/MITLogoLink.tsx b/frontends/ol-utilities/src/components/MITLogoLink.tsx deleted file mode 100644 index c232e78170..0000000000 --- a/frontends/ol-utilities/src/components/MITLogoLink.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import React from "react" - -const PUBLIC_URL = APP_SETTINGS.PUBLIC_URL -const HOME_URL = `${PUBLIC_URL}/` - -const MIT_LOGO_URL = `${PUBLIC_URL}/static/images/mit-logo-learn.svg` - -interface Props { - href?: string - src?: string - className?: string -} - -const MITLogoLink: React.FC = ({ href, src, className }) => ( - - MIT Logo - -) - -export default MITLogoLink diff --git a/frontends/ol-utilities/src/components/index.ts b/frontends/ol-utilities/src/components/index.ts deleted file mode 100644 index 1281be3867..0000000000 --- a/frontends/ol-utilities/src/components/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default as MITLogoLink } from "./MITLogoLink" diff --git a/frontends/ol-utilities/src/index.ts b/frontends/ol-utilities/src/index.ts index a0497c81cc..c46078b796 100644 --- a/frontends/ol-utilities/src/index.ts +++ b/frontends/ol-utilities/src/index.ts @@ -1,7 +1,6 @@ // eslint-disable-next-line @typescript-eslint/triple-slash-reference /// -export * from "./components" export * from "./styles" export * from "./date/format" From e1e1e2aff2ef08c91a22cb072a951dbbefc521ac Mon Sep 17 00:00:00 2001 From: Doof Date: Tue, 1 Oct 2024 14:27:37 +0000 Subject: [PATCH 29/48] Release 0.20.0 --- RELEASE.rst | 14 ++++++++++++++ main/settings.py | 2 +- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/RELEASE.rst b/RELEASE.rst index 75cb020984..c95e44d2b4 100644 --- a/RELEASE.rst +++ b/RELEASE.rst @@ -1,6 +1,20 @@ Release Notes ============= +Version 0.20.0 +-------------- + +- updated header (#1622) +- Switch to using full name (#1621) +- Update dependency lxml to v5 (#1554) +- Remove health checks against opensearch (#1620) +- Add additional event capturing for some interactions (#1596) +- Revert "changes for formatting search subscription emails" +- changes for formatting search subscription emails +- Add custom label for percolate query subscriptions (#1610) +- Update Python to v3.12.6 (#1593) +- [pre-commit.ci] pre-commit autoupdate (#1601) + Version 0.19.6 (Released September 27, 2024) -------------- diff --git a/main/settings.py b/main/settings.py index 1373918998..edb1f76198 100644 --- a/main/settings.py +++ b/main/settings.py @@ -33,7 +33,7 @@ from main.settings_pluggy import * # noqa: F403 from openapi.settings_spectacular import open_spectacular_settings -VERSION = "0.19.6" +VERSION = "0.20.0" log = logging.getLogger() From c20662a5c56090d638730b85bc117d33ffa8662e Mon Sep 17 00:00:00 2001 From: Shankar Ambady Date: Tue, 1 Oct 2024 10:41:00 -0400 Subject: [PATCH 30/48] Email for saved searches (#1619) * fixing subject for saved search * changes for formatting search subscription emails * returning channel only if channel subscription type * fixing tests * grouping by individual queries * adding search subscription email sending to celery tasks * reverting iterating through individual queries * removing unused template * fixing resource type display in emails * adding docstring and fixing resource type description * fixing test * fixing test * fixing test --- learning_resources_search/models.py | 7 ++++-- learning_resources_search/models_test.py | 16 +++++++++--- learning_resources_search/tasks.py | 31 ++++++++++++++++++------ learning_resources_search/tasks_test.py | 8 +++--- main/settings_celery.py | 5 ++++ 5 files changed, 51 insertions(+), 16 deletions(-) diff --git a/learning_resources_search/models.py b/learning_resources_search/models.py index 8b10a69981..d6a35667ba 100644 --- a/learning_resources_search/models.py +++ b/learning_resources_search/models.py @@ -45,14 +45,17 @@ def source_description(self): channel = self.source_channel() if self.display_label: return self.display_label - if channel: + if channel and self.source_type == self.CHANNEL_SUBSCRIPTION_TYPE: return channel.title return self.original_url_params() def source_channel(self): original_query_params = self.original_url_params() channels_filtered = Channel.objects.filter(search_filter=original_query_params) - if channels_filtered.exists(): + if ( + channels_filtered.exists() + and self.source_type == self.CHANNEL_SUBSCRIPTION_TYPE + ): return channels_filtered.first() return None diff --git a/learning_resources_search/models_test.py b/learning_resources_search/models_test.py index 3a6058e2ea..ddf1f5fab3 100644 --- a/learning_resources_search/models_test.py +++ b/learning_resources_search/models_test.py @@ -12,6 +12,7 @@ from learning_resources_search.indexing_api import ( get_reindexing_alias_name, ) +from learning_resources_search.models import PercolateQuery pytestmark = [pytest.mark.django_db, pytest.mark.usefixtures("mocked_es")] @@ -63,7 +64,9 @@ def test_percolate_query_unit_labels(mocker, mocked_es): "yearly_decay_percent": None, } query = PercolateQueryFactory.create( - original_query=original_query, query=original_query + original_query=original_query, + query=original_query, + source_type=PercolateQuery.CHANNEL_SUBSCRIPTION_TYPE, ) assert query.original_url_params() == "offered_by=mitx" assert query.source_label() == "unit" @@ -91,7 +94,9 @@ def test_percolate_query_topic_labels(mocker, mocked_es): "yearly_decay_percent": None, } query = PercolateQueryFactory.create( - original_query=original_query, query=original_query + original_query=original_query, + query=original_query, + source_type=PercolateQuery.CHANNEL_SUBSCRIPTION_TYPE, ) assert query.original_url_params() == "topic=Math" assert query.source_label() == "topic" @@ -118,7 +123,9 @@ def test_percolate_query_department_labels(mocker, mocked_es): "yearly_decay_percent": None, } query = PercolateQueryFactory.create( - original_query=original_query, query=original_query + original_query=original_query, + query=original_query, + source_type=PercolateQuery.CHANNEL_SUBSCRIPTION_TYPE, ) assert query.original_url_params() == "department=physics" assert query.source_label() == "department" @@ -196,6 +203,9 @@ def encode_params(oparams): original_query=original_query, query=original_query, display_label=test_label, + source_type=PercolateQuery.CHANNEL_SUBSCRIPTION_TYPE + if is_channel_query + else PercolateQuery.SEARCH_SUBSCRIPTION_TYPE, ) assert query.original_url_params() == encode_params(original_query) if not is_channel_query: diff --git a/learning_resources_search/tasks.py b/learning_resources_search/tasks.py index b3a07cf71c..6585e8172a 100644 --- a/learning_resources_search/tasks.py +++ b/learning_resources_search/tasks.py @@ -18,6 +18,7 @@ from opensearchpy.exceptions import NotFoundError, RequestError from requests.models import PreparedRequest +from learning_resources.constants import LearningResourceType from learning_resources.etl.constants import RESOURCE_FILE_ETL_SOURCES from learning_resources.models import ( ContentFile, @@ -167,7 +168,7 @@ def _infer_percolate_group(percolate_query): elif key == "offered_by": return LearningResourceOfferor.objects.get(code=val[0]).name return val[0] - return None + return percolate_query.original_url_params() def _infer_percolate_group_url(percolate_query): @@ -181,6 +182,8 @@ def _infer_percolate_group_url(percolate_query): query_string_params = {k: v for k, v in original_query.items() if v} if "endpoint" in query_string_params: query_string_params.pop("endpoint") + if "sortby" not in query_string_params: + query_string_params["sortby"] = "new" query_string = urlencode(query_string_params, doseq=True) return frontend_absolute_url(f"/search?{query_string}") @@ -232,7 +235,9 @@ def _get_percolated_rows(resources, subscription_type): "resource_image_url": resource.image.url if resource.image else "", - "resource_type": resource.resource_type, + "resource_type": LearningResourceType[ + resource.resource_type + ].value, "user_id": user, "source_label": query.source_label(), "source_channel_type": source_channel.channel_type @@ -881,23 +886,35 @@ def finish_recreate_index(results, backing_indices): def _generate_subscription_digest_subject( sample_course, source_name, unique_resource_types, total_count, shortform ): - prefix = "" if shortform else "MIT Learn: " + """ + Generate the subject line and/or content header for subscription emails + Args: + sample_course (a learning resource): A sample resource to reference + source_name (string): the subscription type (saved_search etc) + unique_resource_types (list): set of unique resource types in the email + total_count (int): total number of resources in the email + shortform (bool): if False return the (longer) email subject + otherwise short content header + """ + prefix = "" if shortform else "MIT Learn: " + resource_type = unique_resource_types.pop() if sample_course["source_channel_type"] == "saved_search": + if shortform: + return f"New {resource_type}{pluralize(total_count)} from MIT Learn" return ( f"{prefix}New" - f' "{source_name}" ' - f"{unique_resource_types.pop().capitalize()}{pluralize(total_count)}" + f" {resource_type}{pluralize(total_count)}: " + f"{sample_course['resource_title']}" ) preposition = "from" if sample_course["source_channel_type"] == "topic": preposition = "in" suffix = "" if shortform else f": {sample_course['resource_title']}" - return ( f"{prefix}New" - f" {unique_resource_types.pop().capitalize()}{pluralize(total_count)} " + f" {resource_type}{pluralize(total_count)} " f"{preposition} {source_name}{suffix}" ) diff --git a/learning_resources_search/tasks_test.py b/learning_resources_search/tasks_test.py index b3b8bb0174..a285f40f24 100644 --- a/learning_resources_search/tasks_test.py +++ b/learning_resources_search/tasks_test.py @@ -951,7 +951,7 @@ def test_subscription_digest_subject(): total_count=1, shortform=False, ) - assert subject_line == "MIT Learn: New Program in electronics: robotics" + assert subject_line == "MIT Learn: New program in electronics: robotics" sample_course = {"source_channel_type": "podcast", "resource_title": "robotics"} resource_types = {"program"} @@ -963,7 +963,7 @@ def test_subscription_digest_subject(): total_count=9, shortform=False, ) - assert subject_line == "MIT Learn: New Programs from xpro: robotics" + assert subject_line == "MIT Learn: New programs from xpro: robotics" resource_types = {"podcast"} subject_line = _generate_subscription_digest_subject( @@ -973,13 +973,13 @@ def test_subscription_digest_subject(): total_count=19, shortform=False, ) - assert subject_line == "MIT Learn: New Podcasts from engineering: robotics" + assert subject_line == "MIT Learn: New podcasts from engineering: robotics" resource_types = {"course"} subject_line = _generate_subscription_digest_subject( sample_course, "management", resource_types, 19, shortform=True ) - assert subject_line == "New Courses from management" + assert subject_line == "New courses from management" def test_update_featured_rank(mocker, offeror_featured_lists): diff --git a/main/settings_celery.py b/main/settings_celery.py index 7fc24e41d4..8209830a9a 100644 --- a/main/settings_celery.py +++ b/main/settings_celery.py @@ -130,6 +130,11 @@ "schedule": crontab(minute=30, hour=18), # 2:30pm EST "kwargs": {"period": "daily", "subscription_type": "channel_subscription_type"}, }, + "send-search-subscription-emails-every-1-days": { + "task": "learning_resources_search.tasks.send_subscription_emails", + "schedule": crontab(minute=0, hour=19), # 3:00pm EST + "kwargs": {"period": "daily", "subscription_type": "search_subscription_type"}, + }, "update-search-featured-ranks-1-days": { "task": "learning_resources_search.tasks.update_featured_rank", "schedule": crontab(minute=30, hour=7), # 3:30am EST From 3ca3f7a85a4d449536709cba1ce317ffadf2e655 Mon Sep 17 00:00:00 2001 From: Doof Date: Tue, 1 Oct 2024 15:25:13 +0000 Subject: [PATCH 31/48] Release date for 0.20.0 --- RELEASE.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/RELEASE.rst b/RELEASE.rst index c95e44d2b4..f6e3285ae1 100644 --- a/RELEASE.rst +++ b/RELEASE.rst @@ -1,7 +1,7 @@ Release Notes ============= -Version 0.20.0 +Version 0.20.0 (Released October 01, 2024) -------------- - updated header (#1622) From 44221acdde92ae14ec2f1ba7823200c29e3a9d5c Mon Sep 17 00:00:00 2001 From: Matt Bertrand Date: Tue, 1 Oct 2024 13:24:10 -0400 Subject: [PATCH 32/48] Retry recreate_index subtasks on worker exit (#1615) --- docker-compose.apps.yml | 3 +-- learning_resources_search/tasks.py | 32 +++++++++++++++++++++++------- 2 files changed, 26 insertions(+), 9 deletions(-) diff --git a/docker-compose.apps.yml b/docker-compose.apps.yml index 0b4ac494aa..0f030d18a7 100644 --- a/docker-compose.apps.yml +++ b/docker-compose.apps.yml @@ -50,8 +50,7 @@ services: command: > /bin/bash -c ' sleep 3; - celery -A main.celery:app worker -Q default -B -l ${MITOL_LOG_LEVEL:-INFO} & - celery -A main.celery:app worker -Q edx_content,default -l ${MITOL_LOG_LEVEL:-INFO}' + celery -A main.celery:app worker -E -Q default,edx_content -B -l ${MITOL_LOG_LEVEL:-INFO}' depends_on: db: condition: service_healthy diff --git a/learning_resources_search/tasks.py b/learning_resources_search/tasks.py index 6585e8172a..6a187b9c00 100644 --- a/learning_resources_search/tasks.py +++ b/learning_resources_search/tasks.py @@ -280,7 +280,13 @@ def send_subscription_emails(self, subscription_type, period="daily"): return self.replace(email_tasks) -@app.task(autoretry_for=(RetryError,), retry_backoff=True, rate_limit="600/m") +@app.task( + acks_late=True, + reject_on_worker_lost=True, + autoretry_for=(RetryError, SystemExit), + retry_backoff=True, + rate_limit="600/m", +) def index_learning_resources(ids, resource_type, index_types): """ Index courses @@ -295,7 +301,7 @@ def index_learning_resources(ids, resource_type, index_types): try: with wrap_retry_exception(*SEARCH_CONN_EXCEPTIONS): api.index_learning_resources(ids, resource_type, index_types) - except (RetryError, Ignore): + except (RetryError, Ignore, SystemExit): raise except: # noqa: E722 error = "index_courses threw an error" @@ -353,7 +359,13 @@ def bulk_deindex_percolators(ids): return error -@app.task(autoretry_for=(RetryError,), retry_backoff=True, rate_limit="600/m") +@app.task( + acks_late=True, + reject_on_worker_lost=True, + autoretry_for=(RetryError, SystemExit), + retry_backoff=True, + rate_limit="600/m", +) def bulk_index_percolate_queries(percolate_ids, index_types): """ Bulk index percolate queries for provided percolate query Ids @@ -371,7 +383,7 @@ def bulk_index_percolate_queries(percolate_ids, index_types): PERCOLATE_INDEX_TYPE, index_types, ) - except (RetryError, Ignore): + except (RetryError, Ignore, SystemExit): raise except: # noqa: E722 error = "bulk_index_percolate_queries threw an error" @@ -402,7 +414,13 @@ def index_course_content_files(course_ids, index_types): return error -@app.task(autoretry_for=(RetryError,), retry_backoff=True, rate_limit="600/m") +@app.task( + acks_late=True, + reject_on_worker_lost=True, + autoretry_for=(RetryError, SystemExit), + retry_backoff=True, + rate_limit="600/m", +) def index_content_files( content_file_ids, learning_resource_id, @@ -423,7 +441,7 @@ def index_content_files( api.index_content_files( content_file_ids, learning_resource_id, index_types=index_types ) - except (RetryError, Ignore): + except (RetryError, Ignore, SystemExit): raise except: # noqa: E722 error = "index_content_files threw an error" @@ -848,7 +866,7 @@ def get_update_learning_resource_tasks(resource_type): @app.task( acks_late=True, reject_on_worker_lost=True, - autoretry_for=(RetryError,), + autoretry_for=(RetryError, SystemExit), retry_backoff=True, rate_limit="600/m", ) From 8d5d211008c6ca47de492bba8f5f41cfec4cded5 Mon Sep 17 00:00:00 2001 From: Carey P Gumaer Date: Tue, 1 Oct 2024 13:36:50 -0400 Subject: [PATCH 33/48] fix Safari MIT logo bug (#1631) * apply StyledMITLogoLink styles directly to img element * fix app container padding based on header height --- frontends/mit-learn/src/GlobalStyles.tsx | 8 ++++++-- .../src/page-components/Header/UserMenu.tsx | 16 +++++++++------- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/frontends/mit-learn/src/GlobalStyles.tsx b/frontends/mit-learn/src/GlobalStyles.tsx index db607f4540..82a56db2d6 100644 --- a/frontends/mit-learn/src/GlobalStyles.tsx +++ b/frontends/mit-learn/src/GlobalStyles.tsx @@ -20,8 +20,12 @@ const pageCss = css` } #app-container { - height: calc(100vh - 60px); - margin-top: 60px; + height: calc(100vh - 72px); + margin-top: 72px; + ${theme.breakpoints.down("sm")} { + margin-top: 60px; + height: calc(100vh - 60px); + } } a { diff --git a/frontends/mit-learn/src/page-components/Header/UserMenu.tsx b/frontends/mit-learn/src/page-components/Header/UserMenu.tsx index 6a54459cd6..e5684aee7b 100644 --- a/frontends/mit-learn/src/page-components/Header/UserMenu.tsx +++ b/frontends/mit-learn/src/page-components/Header/UserMenu.tsx @@ -108,13 +108,15 @@ const UserMenuChevron: React.FC<{ open: boolean }> = ({ open }) => { } const StyledMITLogoLink = styled(MITLogoLink)(({ theme }) => ({ - width: "64px", - height: "32px", - marginLeft: "16px", - [theme.breakpoints.down("sm")]: { - width: "48px", - height: "24px", - marginLeft: "0", + img: { + width: "64px", + height: "32px", + marginLeft: "16px", + [theme.breakpoints.down("sm")]: { + width: "48px", + height: "24px", + marginLeft: "0", + }, }, })) From 3665b80e2e2e97dea4351cbefc92cbaf98381005 Mon Sep 17 00:00:00 2001 From: Doof Date: Tue, 1 Oct 2024 17:37:19 +0000 Subject: [PATCH 34/48] Release 0.20.1 --- RELEASE.rst | 7 +++++++ main/settings.py | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/RELEASE.rst b/RELEASE.rst index f6e3285ae1..8d1a2c9988 100644 --- a/RELEASE.rst +++ b/RELEASE.rst @@ -1,6 +1,13 @@ Release Notes ============= +Version 0.20.1 +-------------- + +- fix Safari MIT logo bug (#1631) +- Retry recreate_index subtasks on worker exit (#1615) +- Email for saved searches (#1619) + Version 0.20.0 (Released October 01, 2024) -------------- diff --git a/main/settings.py b/main/settings.py index edb1f76198..191f89b0a4 100644 --- a/main/settings.py +++ b/main/settings.py @@ -33,7 +33,7 @@ from main.settings_pluggy import * # noqa: F403 from openapi.settings_spectacular import open_spectacular_settings -VERSION = "0.20.0" +VERSION = "0.20.1" log = logging.getLogger() From fa8094e700d7ed17b013853a41b9bd1a754e4df3 Mon Sep 17 00:00:00 2001 From: Carey P Gumaer Date: Tue, 1 Oct 2024 15:03:22 -0400 Subject: [PATCH 35/48] set card root to auto height (#1633) --- .../mit-learn/src/pages/UnitsListingPage/UnitsListingPage.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/frontends/mit-learn/src/pages/UnitsListingPage/UnitsListingPage.tsx b/frontends/mit-learn/src/pages/UnitsListingPage/UnitsListingPage.tsx index 2de92a0bdc..c594d7a442 100644 --- a/frontends/mit-learn/src/pages/UnitsListingPage/UnitsListingPage.tsx +++ b/frontends/mit-learn/src/pages/UnitsListingPage/UnitsListingPage.tsx @@ -110,6 +110,9 @@ const UnitContainer = styled.section(({ theme }) => ({ alignItems: "center", maxWidth: DESKTOP_WIDTH, gap: "32px", + ".MitCard-root": { + height: "auto", + }, [theme.breakpoints.down("md")]: { width: "auto", padding: "0 16px", From 4638f210cd6dbbae30cdd760d7abd97ced983b2c Mon Sep 17 00:00:00 2001 From: Carey P Gumaer Date: Tue, 1 Oct 2024 15:39:37 -0400 Subject: [PATCH 36/48] remove border and shadow (#1636) * remove border and shadow * remove nav menu button hover state --- frontends/mit-learn/src/page-components/Header/Header.tsx | 5 +---- .../mit-learn/src/page-components/Header/MenuButton.tsx | 5 ----- 2 files changed, 1 insertion(+), 9 deletions(-) diff --git a/frontends/mit-learn/src/page-components/Header/Header.tsx b/frontends/mit-learn/src/page-components/Header/Header.tsx index 64a6d81fa5..f0df377915 100644 --- a/frontends/mit-learn/src/page-components/Header/Header.tsx +++ b/frontends/mit-learn/src/page-components/Header/Header.tsx @@ -44,20 +44,17 @@ import MITLogoLink from "../MITLogoLink/MITLogoLink" const Bar = styled(AppBar)(({ theme }) => ({ padding: "16px 8px", - borderBottom: `4px solid ${theme.custom.colors.darkGray2}`, backgroundColor: theme.custom.colors.navGray, + boxShadow: "none", display: "flex", justifyContent: "space-between", flexDirection: "column", - boxShadow: "0px 3px 35px 0px rgba(23, 30, 42, 0.50)", ".MuiToolbar-root": { minHeight: "auto", }, [theme.breakpoints.down("sm")]: { height: "60px", padding: "0", - borderBottom: `1px solid ${theme.custom.colors.darkGray2}`, - boxShadow: "0px -2px 20px 0px rgba(0, 0, 0, 0.05)", }, })) diff --git a/frontends/mit-learn/src/page-components/Header/MenuButton.tsx b/frontends/mit-learn/src/page-components/Header/MenuButton.tsx index add3d730a7..ba9b4e2582 100644 --- a/frontends/mit-learn/src/page-components/Header/MenuButton.tsx +++ b/frontends/mit-learn/src/page-components/Header/MenuButton.tsx @@ -40,16 +40,11 @@ const StyledMenuButton = styled.button(({ theme }) => ({ transition: `background ${theme.transitions.duration.short}ms`, cursor: "pointer", borderStyle: "none", - opacity: 0.5, svg: { color: theme.custom.colors.white, }, [theme.breakpoints.down("sm")]: { padding: "4px 0", - opacity: 1, - }, - "&:hover": { - opacity: 1, }, })) From 3c63525e6dbea2e5887b579be3d6cc554f672c3d Mon Sep 17 00:00:00 2001 From: Doof Date: Tue, 1 Oct 2024 19:44:03 +0000 Subject: [PATCH 37/48] Release date for 0.20.1 --- RELEASE.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/RELEASE.rst b/RELEASE.rst index 8d1a2c9988..2d9190e053 100644 --- a/RELEASE.rst +++ b/RELEASE.rst @@ -1,7 +1,7 @@ Release Notes ============= -Version 0.20.1 +Version 0.20.1 (Released October 01, 2024) -------------- - fix Safari MIT logo bug (#1631) From 2004dfae479c921de478dd8b910af98071eaa0e6 Mon Sep 17 00:00:00 2001 From: Doof Date: Tue, 1 Oct 2024 19:44:38 +0000 Subject: [PATCH 38/48] Release 0.20.2 --- RELEASE.rst | 6 ++++++ main/settings.py | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/RELEASE.rst b/RELEASE.rst index 2d9190e053..e144694844 100644 --- a/RELEASE.rst +++ b/RELEASE.rst @@ -1,6 +1,12 @@ Release Notes ============= +Version 0.20.2 +-------------- + +- remove border and shadow (#1636) +- set card root to auto height (#1633) + Version 0.20.1 (Released October 01, 2024) -------------- diff --git a/main/settings.py b/main/settings.py index 191f89b0a4..46fcc3c304 100644 --- a/main/settings.py +++ b/main/settings.py @@ -33,7 +33,7 @@ from main.settings_pluggy import * # noqa: F403 from openapi.settings_spectacular import open_spectacular_settings -VERSION = "0.20.1" +VERSION = "0.20.2" log = logging.getLogger() From 9b2b04ecec7a567a4988d4723c03d2dad6b8a3b3 Mon Sep 17 00:00:00 2001 From: Doof Date: Tue, 1 Oct 2024 20:04:37 +0000 Subject: [PATCH 39/48] Release date for 0.20.2 --- RELEASE.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/RELEASE.rst b/RELEASE.rst index e144694844..c4fb14ca63 100644 --- a/RELEASE.rst +++ b/RELEASE.rst @@ -1,7 +1,7 @@ Release Notes ============= -Version 0.20.2 +Version 0.20.2 (Released October 01, 2024) -------------- - remove border and shadow (#1636) From c7d10e85206444da2443f5bc2456daec3b74fa08 Mon Sep 17 00:00:00 2001 From: Matt Bertrand Date: Wed, 2 Oct 2024 08:50:30 -0400 Subject: [PATCH 40/48] raise SystemExit as a RetryError (#1635) --- learning_resources_search/tasks.py | 18 ++++++++++++------ learning_resources_search/tasks_test.py | 12 ++++++++++++ 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/learning_resources_search/tasks.py b/learning_resources_search/tasks.py index 6a187b9c00..dc2ea778f4 100644 --- a/learning_resources_search/tasks.py +++ b/learning_resources_search/tasks.py @@ -283,7 +283,7 @@ def send_subscription_emails(self, subscription_type, period="daily"): @app.task( acks_late=True, reject_on_worker_lost=True, - autoretry_for=(RetryError, SystemExit), + autoretry_for=(RetryError,), retry_backoff=True, rate_limit="600/m", ) @@ -301,8 +301,10 @@ def index_learning_resources(ids, resource_type, index_types): try: with wrap_retry_exception(*SEARCH_CONN_EXCEPTIONS): api.index_learning_resources(ids, resource_type, index_types) - except (RetryError, Ignore, SystemExit): + except (RetryError, Ignore): raise + except SystemExit as err: + raise RetryError(SystemExit.__name__) from err except: # noqa: E722 error = "index_courses threw an error" log.exception(error) @@ -362,7 +364,7 @@ def bulk_deindex_percolators(ids): @app.task( acks_late=True, reject_on_worker_lost=True, - autoretry_for=(RetryError, SystemExit), + autoretry_for=(RetryError,), retry_backoff=True, rate_limit="600/m", ) @@ -383,8 +385,10 @@ def bulk_index_percolate_queries(percolate_ids, index_types): PERCOLATE_INDEX_TYPE, index_types, ) - except (RetryError, Ignore, SystemExit): + except (RetryError, Ignore): raise + except SystemExit as err: + raise RetryError(SystemExit.__name__) from err except: # noqa: E722 error = "bulk_index_percolate_queries threw an error" log.exception(error) @@ -417,7 +421,7 @@ def index_course_content_files(course_ids, index_types): @app.task( acks_late=True, reject_on_worker_lost=True, - autoretry_for=(RetryError, SystemExit), + autoretry_for=(RetryError,), retry_backoff=True, rate_limit="600/m", ) @@ -441,8 +445,10 @@ def index_content_files( api.index_content_files( content_file_ids, learning_resource_id, index_types=index_types ) - except (RetryError, Ignore, SystemExit): + except (RetryError, Ignore): raise + except SystemExit as err: + raise RetryError(SystemExit.__name__) from err except: # noqa: E722 error = "index_content_files threw an error" log.exception(error) diff --git a/learning_resources_search/tasks_test.py b/learning_resources_search/tasks_test.py index a285f40f24..db178b727c 100644 --- a/learning_resources_search/tasks_test.py +++ b/learning_resources_search/tasks_test.py @@ -119,6 +119,18 @@ def raise_thing(): raise_thing() +def test_system_exit_retry(mocker): + """Task should raise a retry error on system exit""" + mocker.patch( + "learning_resources_search.tasks.wrap_retry_exception", side_effect=SystemExit + ) + with pytest.raises(Retry) as exc: + index_learning_resources.delay( + [1], COURSE_TYPE, IndexestoUpdate.current_index.value + ) + assert str(exc.value.args[1]) == "SystemExit" + + @pytest.mark.parametrize( "indexes", [["course"], ["program"]], From 7b2217d73e3728d2651979977d7d089029451d3d Mon Sep 17 00:00:00 2001 From: Shankar Ambady Date: Wed, 2 Oct 2024 13:09:15 -0400 Subject: [PATCH 41/48] "unfollow" confirmation modal and "Unfollow All" button (#1628) * adding initial button * adding working dialog * fixing typecheck lint * fixing styles and checking for existing subscriptions * adding tests * fixing lint * adding another test. fixing dialog header * fixing lint * remove trailing question mark --- .../pages/DashboardPage/SettingsPage.test.tsx | 48 +++++- .../src/pages/DashboardPage/SettingsPage.tsx | 140 ++++++++++++++++-- 2 files changed, 169 insertions(+), 19 deletions(-) diff --git a/frontends/mit-learn/src/pages/DashboardPage/SettingsPage.test.tsx b/frontends/mit-learn/src/pages/DashboardPage/SettingsPage.test.tsx index 38812527f3..16bcb2e1e3 100644 --- a/frontends/mit-learn/src/pages/DashboardPage/SettingsPage.test.tsx +++ b/frontends/mit-learn/src/pages/DashboardPage/SettingsPage.test.tsx @@ -25,10 +25,15 @@ const setupApis = ({ `${urls.userSubscription.check(subscriptionRequest)}`, subscribeResponse, ) - const unsubscribeUrl = urls.userSubscription.delete(subscribeResponse[0]?.id) - setMockResponse.delete(unsubscribeUrl, subscribeResponse[0]) + const unsubscribeUrls = [] + for (const sub of subscribeResponse) { + const unsubscribeUrl = urls.userSubscription.delete(sub?.id) + unsubscribeUrls.push(unsubscribeUrl) + setMockResponse.delete(unsubscribeUrl, sub) + } + return { - unsubscribeUrl, + unsubscribeUrls, } } @@ -46,7 +51,7 @@ describe("SettingsPage", () => { }) test("Clicking 'Unfollow' removes the subscription", async () => { - const { unsubscribeUrl } = setupApis({ + const { unsubscribeUrls } = setupApis({ isAuthenticated: true, isSubscribed: true, subscriptionRequest: {}, @@ -54,13 +59,42 @@ describe("SettingsPage", () => { renderWithProviders() const followList = await screen.findByTestId("follow-list") - const unsubscribeButton = within(followList).getAllByText("Unfollow")[0] - await user.click(unsubscribeButton) + const unsubscribeLink = within(followList).getAllByText("Unfollow")[0] + await user.click(unsubscribeLink) + const unsubscribeButton = await screen.findByTestId("dialog-unfollow") + await user.click(unsubscribeButton) expect(makeRequest).toHaveBeenCalledWith( "delete", - unsubscribeUrl, + unsubscribeUrls[0], undefined, ) }) + + test("Clicking 'Unfollow All' removes all subscriptions", async () => { + const { unsubscribeUrls } = setupApis({ + isAuthenticated: true, + isSubscribed: true, + subscriptionRequest: {}, + }) + renderWithProviders() + const unsubscribeLink = await screen.findByTestId("unfollow-all") + await user.click(unsubscribeLink) + + const unsubscribeButton = await screen.findByTestId("dialog-unfollow") + await user.click(unsubscribeButton) + for (const unsubUrl of unsubscribeUrls) { + expect(makeRequest).toHaveBeenCalledWith("delete", unsubUrl, undefined) + } + }) + test("Unsubscribe from all is hidden if there are no subscriptions", async () => { + setupApis({ + isAuthenticated: true, + isSubscribed: false, + subscriptionRequest: {}, + }) + renderWithProviders() + const unfollowButton = screen.queryByText("Unfollow All") + expect(unfollowButton).not.toBeInTheDocument() + }) }) diff --git a/frontends/mit-learn/src/pages/DashboardPage/SettingsPage.tsx b/frontends/mit-learn/src/pages/DashboardPage/SettingsPage.tsx index 2a135a5178..1e201aaf98 100644 --- a/frontends/mit-learn/src/pages/DashboardPage/SettingsPage.tsx +++ b/frontends/mit-learn/src/pages/DashboardPage/SettingsPage.tsx @@ -1,11 +1,19 @@ import React from "react" -import { PlainList, Typography, Link, styled } from "ol-components" +import { + PlainList, + Typography, + Link, + styled, + Button, + Dialog, + DialogActions, +} from "ol-components" import { useUserMe } from "api/hooks/user" import { useSearchSubscriptionDelete, useSearchSubscriptionList, } from "api/hooks/searchSubscription" - +import * as NiceModal from "@ebay/nice-modal-react" const SOURCE_LABEL_DISPLAY = { topic: "Topic", unit: "MIT Unit", @@ -13,6 +21,10 @@ const SOURCE_LABEL_DISPLAY = { saved_search: "Saved Search", } +const Actions = styled(DialogActions)({ + display: "flex", + "> *": { flex: 1 }, +}) const FollowList = styled(PlainList)(({ theme }) => ({ borderRadius: "8px", background: theme.custom.colors.white, @@ -37,6 +49,29 @@ const SubTitleText = styled(Typography)(({ theme }) => ({ ...theme.typography.body2, })) +const SettingsHeader = styled.div(({ theme }) => ({ + display: "flex", + alignItems: "center", + alignSelf: "stretch", + [theme.breakpoints.down("md")]: { + paddingBottom: "8px", + }, +})) + +const SettingsHeaderLeft = styled.div({ + display: "flex", + flexDirection: "column", + alignItems: "flex-start", + flex: "1 0 0", +}) + +const SettingsHeaderRight = styled.div(({ theme }) => ({ + display: "flex", + [theme.breakpoints.down("md")]: { + display: "none", + }, +})) + const ListItem = styled.li(({ theme }) => [ { padding: "16px 32px", @@ -83,22 +118,100 @@ const ListItemBody: React.FC = ({ ) } +type UnfollowDialogProps = { + subscriptionIds?: number[] + subscriptionName?: string +} +const UnfollowDialog = NiceModal.create( + ({ subscriptionIds, subscriptionName }: UnfollowDialogProps) => { + const modal = NiceModal.useModal() + const subscriptionDelete = useSearchSubscriptionDelete() + const unsubscribe = subscriptionDelete.mutate + return ( + + + + + + } + > + {subscriptionIds?.length === 1 ? ( + <> + Are you sure you want to unfollow {subscriptionName}? + + ) : ( + <> + Are you sure you want to Unfollow All? You will stop getting + emails for all topics, academic departments, and MIT units you are + following. + + )} + + ) + }, +) + const SettingsPage: React.FC = () => { const { data: user } = useUserMe() - const subscriptionDelete = useSearchSubscriptionDelete() + const subscriptionList = useSearchSubscriptionList({ enabled: !!user?.is_authenticated, }) - const unsubscribe = subscriptionDelete.mutate if (!user || subscriptionList.isLoading) return null return ( <> - Following - - All topics, academic departments, and MIT units you are following. - + + + Following + + All topics, academic departments, and MIT units you are following. + + + {subscriptionList?.data && subscriptionList?.data?.length > 1 ? ( + + + + ) : ( + <> + )} + {subscriptionList?.data?.map((subscriptionItem) => ( @@ -111,10 +224,13 @@ const SettingsPage: React.FC = () => { } /> { - event.preventDefault() - unsubscribe(subscriptionItem.id) - }} + onClick={() => + NiceModal.show(UnfollowDialog, { + subscriptionIds: [subscriptionItem.id], + subscriptionName: subscriptionItem.source_description, + id: subscriptionItem.id.toString(), + }) + } > Unfollow From 6825dcf8e4c204819f9766eb1dcd20c9a3c952af Mon Sep 17 00:00:00 2001 From: Anastasia Beglova Date: Wed, 2 Oct 2024 14:03:05 -0400 Subject: [PATCH 42/48] add is_incomplete_or_stale (#1627) --- learning_resources/factories.py | 1 + learning_resources_search/api_test.py | 4 ++ learning_resources_search/constants.py | 2 + learning_resources_search/serializers.py | 16 ++++++-- learning_resources_search/serializers_test.py | 38 ++++++++++++++----- 5 files changed, 49 insertions(+), 12 deletions(-) diff --git a/learning_resources/factories.py b/learning_resources/factories.py index a3996cf0fc..5252d2894e 100644 --- a/learning_resources/factories.py +++ b/learning_resources/factories.py @@ -213,6 +213,7 @@ class LearningResourceFactory(DjangoModelFactory): departments = factory.PostGeneration(_post_gen_departments) topics = factory.PostGeneration(_post_gen_topics) content_tags = factory.PostGeneration(_post_gen_tags) + completeness = 1 published = True delivery = factory.List(random.choices(LearningResourceDelivery.names())) # noqa: S311 professional = factory.LazyAttribute( diff --git a/learning_resources_search/api_test.py b/learning_resources_search/api_test.py index 86b810113b..50952da6c7 100644 --- a/learning_resources_search/api_test.py +++ b/learning_resources_search/api_test.py @@ -1445,6 +1445,7 @@ def test_execute_learn_search_for_learning_resource_query(opensearch): "is_learning_material", "resource_age_date", "featured_rank", + "is_incomplete_or_stale", ] }, } @@ -1921,6 +1922,7 @@ def test_execute_learn_search_with_script_score( "is_learning_material", "resource_age_date", "featured_rank", + "is_incomplete_or_stale", ] }, } @@ -2349,6 +2351,7 @@ def test_execute_learn_search_with_min_score(mocker, settings, opensearch): "is_learning_material", "resource_age_date", "featured_rank", + "is_incomplete_or_stale", ] }, } @@ -2559,6 +2562,7 @@ def test_execute_learn_search_for_content_file_query(opensearch): "is_learning_material", "resource_age_date", "featured_rank", + "is_incomplete_or_stale", ] }, } diff --git a/learning_resources_search/constants.py b/learning_resources_search/constants.py index 6bcc47e1f0..1487edb061 100644 --- a/learning_resources_search/constants.py +++ b/learning_resources_search/constants.py @@ -115,6 +115,7 @@ class FilterConfig: }, "free": {"type": "boolean"}, "is_learning_material": {"type": "boolean"}, + "is_incomplete_or_stale": {"type": "boolean"}, "delivery": { "type": "nested", "properties": { @@ -416,6 +417,7 @@ class FilterConfig: "is_learning_material", "resource_age_date", "featured_rank", + "is_incomplete_or_stale", ] LEARNING_RESOURCE_SEARCH_SORTBY_OPTIONS = { diff --git a/learning_resources_search/serializers.py b/learning_resources_search/serializers.py index b340605169..2addd6fa4d 100644 --- a/learning_resources_search/serializers.py +++ b/learning_resources_search/serializers.py @@ -125,6 +125,9 @@ def serialize_learning_resource_for_update( dict: The serialized and transformed resource data """ + STALENESS_CUTOFF = 2010 + COMPLETENESS_CUTOFF = 0.5 + serialized_data = LearningResourceSerializer(instance=learning_resource_obj).data if learning_resource_obj.resource_type == LearningResourceType.course.name: @@ -146,15 +149,22 @@ def serialize_learning_resource_for_update( else: featured_rank = None + resource_age_date = get_resource_age_date( + learning_resource_obj, serialized_data["resource_category"] + ) + + is_incomplete_or_stale = ( + resource_age_date and resource_age_date.year <= STALENESS_CUTOFF + ) or (learning_resource_obj.completeness < COMPLETENESS_CUTOFF) + return { "resource_relations": {"name": "resource"}, "created_on": learning_resource_obj.created_on, "is_learning_material": serialized_data["resource_category"] == LEARNING_MATERIAL_RESOURCE_CATEGORY, - "resource_age_date": get_resource_age_date( - learning_resource_obj, serialized_data["resource_category"] - ), + "resource_age_date": resource_age_date, "featured_rank": featured_rank, + "is_incomplete_or_stale": is_incomplete_or_stale, **serialized_data, } diff --git a/learning_resources_search/serializers_test.py b/learning_resources_search/serializers_test.py index 92eddbe150..43d97c6154 100644 --- a/learning_resources_search/serializers_test.py +++ b/learning_resources_search/serializers_test.py @@ -615,6 +615,7 @@ def test_serialize_bulk_learning_resources(mocker): "resource_age_date": mocker.ANY, "is_learning_material": mocker.ANY, "featured_rank": None, + "is_incomplete_or_stale": mocker.ANY, **LearningResourceSerializer(instance=resource).data, } @@ -637,30 +638,47 @@ def test_serialize_bulk_learning_resources(mocker): @pytest.mark.parametrize("is_professional", [True, False]) @pytest.mark.parametrize("no_price", [True, False]) @pytest.mark.parametrize("has_featured_rank", [True, False]) -def test_serialize_learning_resource_for_bulk( - mocker, resource_type, is_professional, no_price, has_featured_rank +@pytest.mark.parametrize("is_stale", [True, False]) +@pytest.mark.parametrize("is_incomplete", [True, False]) +def test_serialize_learning_resource_for_bulk( # noqa: PLR0913 + mocker, + resource_type, + is_professional, + no_price, + has_featured_rank, + is_stale, + is_incomplete, ): """ Test that serialize_program_for_bulk yields a valid LearningResourceSerializer for resource types other than "course" The "course" resource type is tested by `test_serialize_course_numbers_for_bulk` below. """ + completeness = 0.24 if is_incomplete else 0.75 resource = factories.LearningResourceFactory.create( - resource_type=resource_type, professional=is_professional, runs=[] + resource_type=resource_type, + professional=is_professional, + runs=[], + completeness=completeness, ) + LearningResourceRunFactory.create( learning_resource=resource, prices=[Decimal(0.00 if no_price else 1.00)] ) + + resource_age_date = datetime( + 2009 if is_stale else 2024, 1, 1, 1, 1, 1, 0, tzinfo=UTC + ) + + mocker.patch( + "learning_resources_search.serializers.get_resource_age_date", + return_value=resource_age_date, + ) free_dict = { "free": resource_type not in [LearningResourceType.program.name, LearningResourceType.course.name] or (no_price and not is_professional) } - mocker.patch( - "learning_resources_search.serializers.get_resource_age_date", - return_value=datetime(2024, 1, 1, 1, 1, 1, 0, tzinfo=UTC), - ) - if has_featured_rank: mocker.patch( "learning_resources_search.serializers.random", @@ -692,8 +710,9 @@ def test_serialize_learning_resource_for_bulk( "resource_relations": {"name": "resource"}, "created_on": resource.created_on, "is_learning_material": resource.resource_type not in ["course", "program"], - "resource_age_date": datetime(2024, 1, 1, 1, 1, 1, 0, tzinfo=UTC), + "resource_age_date": resource_age_date, "featured_rank": 3.4 if has_featured_rank else None, + "is_incomplete_or_stale": is_incomplete or is_stale, **free_dict, **LearningResourceSerializer(resource).data, } @@ -818,6 +837,7 @@ def test_serialize_course_numbers_for_bulk( "is_learning_material": False, "resource_age_date": datetime(2024, 1, 1, 1, 1, 1, 0, tzinfo=UTC), "featured_rank": None, + "is_incomplete_or_stale": False, **LearningResourceSerializer(resource).data, } expected_data["course"]["course_numbers"][0] = { From 0464a548789977a087afa67f54b76fe10193043a Mon Sep 17 00:00:00 2001 From: Doof Date: Wed, 2 Oct 2024 18:04:52 +0000 Subject: [PATCH 43/48] Release 0.20.3 --- RELEASE.rst | 7 +++++++ main/settings.py | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/RELEASE.rst b/RELEASE.rst index c4fb14ca63..d24eb10fd1 100644 --- a/RELEASE.rst +++ b/RELEASE.rst @@ -1,6 +1,13 @@ Release Notes ============= +Version 0.20.3 +-------------- + +- add is_incomplete_or_stale (#1627) +- "unfollow" confirmation modal and "Unfollow All" button (#1628) +- raise SystemExit as a RetryError (#1635) + Version 0.20.2 (Released October 01, 2024) -------------- diff --git a/main/settings.py b/main/settings.py index 46fcc3c304..61a6a9113c 100644 --- a/main/settings.py +++ b/main/settings.py @@ -33,7 +33,7 @@ from main.settings_pluggy import * # noqa: F403 from openapi.settings_spectacular import open_spectacular_settings -VERSION = "0.20.2" +VERSION = "0.20.3" log = logging.getLogger() From ed4423278b6b1155d7dc46969a34bd223150c6ea Mon Sep 17 00:00:00 2001 From: Shankar Ambady Date: Wed, 2 Oct 2024 15:43:13 -0400 Subject: [PATCH 44/48] updating email template with new logo (#1638) * updating email template with new logo * adding copy update for unit * adding trailing slash * adding trailing slash --- .../migrations/0015_unit_page_copy_updates.py | 61 ++++++++++++ .../public/images/mit-block-logo.jpg | Bin 0 -> 3218 bytes .../public/images/mit-learn-logo.jpg | Bin 0 -> 11182 bytes .../public/images/mit-logo-learn.jpg | Bin 3108 -> 0 bytes main/templates/email/email_base.html | 87 ++++++++++++------ .../email/subscribed_channel_digest.html | 2 +- 6 files changed, 119 insertions(+), 31 deletions(-) create mode 100644 data_fixtures/migrations/0015_unit_page_copy_updates.py create mode 100644 frontends/mit-learn/public/images/mit-block-logo.jpg create mode 100644 frontends/mit-learn/public/images/mit-learn-logo.jpg delete mode 100644 frontends/mit-learn/public/images/mit-logo-learn.jpg diff --git a/data_fixtures/migrations/0015_unit_page_copy_updates.py b/data_fixtures/migrations/0015_unit_page_copy_updates.py new file mode 100644 index 0000000000..769ad6cc9d --- /dev/null +++ b/data_fixtures/migrations/0015_unit_page_copy_updates.py @@ -0,0 +1,61 @@ +# Generated by Django 4.2.14 on 2024-07-16 17:30 + +from django.db import migrations + +fixtures = [ + { + "name": "mitpe", + "offeror_configuration": { + "value_prop": ( + "MIT Professional Education is a leader in technology and " + "engineering education for working professionals pursuing " + "career advancement, and organizations seeking to meet modern-day " + "challenges by expanding the knowledge and skills of their employees. " + "Courses are delivered in a range of formats—in-person (on-campus " + "and live online), online, and through hybrid approaches—to " + "meet the needs of today's learners." + ), + }, + "channel_configuration": { + "sub_heading": ( + "MIT Professional Education is a leader in technology and " + "engineering education for working professionals pursuing " + "career advancement, and organizations seeking to meet modern-day " + "challenges by expanding the knowledge and skills of their employees. " + "Courses are delivered in a range of formats—in-person (on-campus " + "and live online), online, and through hybrid approaches—to " + "meet the needs of today's learners." + ), + }, + }, +] + + +def update_copy(apps, schema_editor): + Channel = apps.get_model("channels", "Channel") + LearningResourceOfferor = apps.get_model( + "learning_resources", "LearningResourceOfferor" + ) + for fixture in fixtures: + channel_configuration_updates = fixture["channel_configuration"] + offeror_configuration_updates = fixture["offeror_configuration"] + channel = Channel.objects.get(name=fixture["name"]) + if Channel.objects.filter(name=fixture["name"]).exists(): + for key, val in channel_configuration_updates.items(): + channel.configuration[key] = val + channel.save() + if LearningResourceOfferor.objects.filter(code=fixture["name"]).exists(): + offeror = LearningResourceOfferor.objects.get(code=fixture["name"]) + for key, val in offeror_configuration_updates.items(): + setattr(offeror, key, val) + offeror.save() + + +class Migration(migrations.Migration): + dependencies = [ + ("data_fixtures", "0014_add_department_SP"), + ] + + operations = [ + migrations.RunPython(update_copy, migrations.RunPython.noop), + ] diff --git a/frontends/mit-learn/public/images/mit-block-logo.jpg b/frontends/mit-learn/public/images/mit-block-logo.jpg new file mode 100644 index 0000000000000000000000000000000000000000..5e61d900e80ce7cf358c6b4554b7175906451b0c GIT binary patch literal 3218 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 Fe*v$RoR|Or literal 0 HcmV?d00001 diff --git a/frontends/mit-learn/public/images/mit-logo-learn.jpg b/frontends/mit-learn/public/images/mit-logo-learn.jpg deleted file mode 100644 index eb5b0bc61829fab438d1a458b8e7e8af074780c0..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3108 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 diff --git a/main/templates/email/email_base.html b/main/templates/email/email_base.html index c2646e8720..5ee577f21f 100644 --- a/main/templates/email/email_base.html +++ b/main/templates/email/email_base.html @@ -249,49 +249,76 @@ - - + - - - + + + +
- {% block logo %} -

- MIT-Learn -

- {% endblock %} +
+ MIT-Learn + + MIT-Learn
+ + + + - {% block content %}{% endblock %} @@ -332,7 +359,7 @@

If you don't want to receive these emails in the future, you can unsubscribe.

diff --git a/main/templates/email/subscribed_channel_digest.html b/main/templates/email/subscribed_channel_digest.html index f0f805e4a4..cf2ac84f78 100644 --- a/main/templates/email/subscribed_channel_digest.html +++ b/main/templates/email/subscribed_channel_digest.html @@ -17,7 +17,7 @@ >

void + setPage: (page: number) => void +} + +const { POSTHOG } = APP_SETTINGS + +/** + * A wrapper around SearchInput that handles a little application logic like + * - resetting search page to 1 on submission + * - firing tracking events + */ +const SearchField: React.FC = ({ + onSubmit, + setPage, + ...others +}) => { + const posthog = usePostHog() + const handleSubmit: SearchInputProps["onSubmit"] = ( + event, + { isEnter } = {}, + ) => { + onSubmit(event) + setPage(1) + if (POSTHOG?.api_key) { + posthog.capture("search_update", { isEnter: isEnter }) + } + } + + return +} + +export { SearchField } diff --git a/frontends/mit-learn/src/pages/ChannelPage/ChannelSearch.test.tsx b/frontends/mit-learn/src/pages/ChannelPage/ChannelSearch.test.tsx index 8063466e3e..0e77812442 100644 --- a/frontends/mit-learn/src/pages/ChannelPage/ChannelSearch.test.tsx +++ b/frontends/mit-learn/src/pages/ChannelPage/ChannelSearch.test.tsx @@ -1,4 +1,4 @@ -import { screen, within, waitFor, renderTestApp } from "@/test-utils" +import { screen, within, waitFor, renderTestApp, user } from "@/test-utils" import { setMockResponse, urls, factories, makeRequest } from "api/test-utils" import type { LearningResourcesSearchResponse } from "api" import invariant from "tiny-invariant" @@ -264,4 +264,45 @@ describe("ChannelSearch", () => { } }, ) + + test("Submitting search text updates URL correctly", async () => { + const resources = factories.learningResources.resources({ + count: 10, + }).results + const { channel } = setMockApiResponses({ + search: { + count: 1000, + metadata: { + aggregations: { + resource_type: [ + { key: "course", doc_count: 100 }, + { key: "podcast", doc_count: 200 }, + { key: "program", doc_count: 300 }, + { key: "irrelevant", doc_count: 400 }, + ], + }, + suggestions: [], + }, + results: resources, + }, + }) + setMockResponse.get(urls.userMe.get(), {}) + + const initialSearch = "?q=meow&page=2" + const finalSearch = "?q=woof" + + const { location } = renderTestApp({ + url: `/c/${channel.channel_type}/${channel.name}${initialSearch}`, + }) + + const queryInput = await screen.findByRole("textbox", { + name: "Search for", + }) + expect(queryInput.value).toBe("meow") + await user.clear(queryInput) + await user.paste("woof") + expect(location.current.search).toBe(initialSearch) + await user.click(screen.getByRole("button", { name: "Search" })) + expect(location.current.search).toBe(finalSearch) + }) }) diff --git a/frontends/mit-learn/src/pages/ChannelPage/ChannelSearch.tsx b/frontends/mit-learn/src/pages/ChannelPage/ChannelSearch.tsx index 4b32b3c7ee..2e88ed9bac 100644 --- a/frontends/mit-learn/src/pages/ChannelPage/ChannelSearch.tsx +++ b/frontends/mit-learn/src/pages/ChannelPage/ChannelSearch.tsx @@ -14,7 +14,8 @@ import type { } from "@mitodl/course-search-utils" import { useSearchParams } from "@mitodl/course-search-utils/react-router" import SearchDisplay from "@/page-components/SearchDisplay/SearchDisplay" -import { Container, SearchInput, styled, VisuallyHidden } from "ol-components" +import { Container, styled, VisuallyHidden } from "ol-components" +import { SearchField } from "@/page-components/SearchField/SearchField" import { getFacetManifest } from "@/pages/SearchPage/SearchPage" @@ -30,7 +31,7 @@ const SearchInputContainer = styled(Container)(({ theme }) => ({ }, })) -const StyledSearchInput = styled(SearchInput)({ +const StyledSearchField = styled(SearchField)({ width: "624px", }) @@ -172,7 +173,7 @@ const ChannelSearch: React.FC = ({
Search within {channelTitle} - setCurrentText(e.target.value)} @@ -182,6 +183,7 @@ const ChannelSearch: React.FC = ({ onClear={() => { setCurrentTextAndQuery("") }} + setPage={setPage} /> diff --git a/frontends/mit-learn/src/pages/SearchPage/SearchPage.test.tsx b/frontends/mit-learn/src/pages/SearchPage/SearchPage.test.tsx index 697d3f9ae5..6370ad254a 100644 --- a/frontends/mit-learn/src/pages/SearchPage/SearchPage.test.tsx +++ b/frontends/mit-learn/src/pages/SearchPage/SearchPage.test.tsx @@ -201,25 +201,24 @@ describe("SearchPage", () => { await within(facetsContainer).findByText("Resource Type") }) - test.each([{ withPage: false }, { withPage: true }])( - "Submitting text updates URL", - async ({ withPage }) => { - setMockApiResponses({}) - const urlQueryString = withPage ? "?q=meow&page=2" : "?q=meow" - const { location } = renderWithProviders(, { - url: urlQueryString, - }) - const queryInput = await screen.findByRole("textbox", { - name: "Search for", - }) - expect(queryInput.value).toBe("meow") - await user.clear(queryInput) - await user.paste("woof") - expect(location.current.search).toBe(urlQueryString) - await user.click(screen.getByRole("button", { name: "Search" })) - expect(location.current.search).toBe("?q=woof") - }, - ) + test.each([ + { initialQuery: "?q=meow&page=2", finalQuery: "?q=woof" }, + { initialQuery: "?q=meow", finalQuery: "?q=woof" }, + ])("Submitting text updates URL", async ({ initialQuery, finalQuery }) => { + setMockApiResponses({}) + const { location } = renderWithProviders(, { + url: initialQuery, + }) + const queryInput = await screen.findByRole("textbox", { + name: "Search for", + }) + expect(queryInput.value).toBe("meow") + await user.clear(queryInput) + await user.paste("woof") + expect(location.current.search).toBe(initialQuery) + await user.click(screen.getByRole("button", { name: "Search" })) + expect(location.current.search).toBe(finalQuery) + }) test("unathenticated users do not see admin options", async () => { setMockApiResponses({ diff --git a/frontends/mit-learn/src/pages/SearchPage/SearchPage.tsx b/frontends/mit-learn/src/pages/SearchPage/SearchPage.tsx index 476945824c..1b3cfd56a7 100644 --- a/frontends/mit-learn/src/pages/SearchPage/SearchPage.tsx +++ b/frontends/mit-learn/src/pages/SearchPage/SearchPage.tsx @@ -9,13 +9,8 @@ import { getDepartmentName, } from "@mitodl/course-search-utils" import SearchDisplay from "@/page-components/SearchDisplay/SearchDisplay" -import { - SearchInput, - styled, - Container, - theme, - VisuallyHidden, -} from "ol-components" +import { styled, Container, theme, VisuallyHidden } from "ol-components" +import { SearchField } from "@/page-components/SearchField/SearchField" import type { LearningResourceOfferor } from "api" import { useOfferorsList } from "api/hooks/learningResources" import { capitalize } from "ol-utilities" @@ -56,7 +51,7 @@ const SearchFieldContainer = styled(Container)({ justifyContent: "center", }) -const SearchField = styled(SearchInput)(({ theme }) => ({ +const StyledSearchField = styled(SearchField)(({ theme }) => ({ [theme.breakpoints.down("sm")]: { width: "100%", }, @@ -216,14 +211,6 @@ const SearchPage: React.FC = () => { onFacetsChange, }) - const onSearchTermSubmit = useCallback( - (term: string) => { - setCurrentTextAndQuery(term) - setPage(1) - }, - [setPage, setCurrentTextAndQuery], - ) - const page = +(searchParams.get("page") ?? "1") return ( @@ -234,16 +221,17 @@ const SearchPage: React.FC = () => {
- setCurrentText(e.target.value)} onSubmit={(e) => { - onSearchTermSubmit(e.target.value) + setCurrentTextAndQuery(e.target.value) }} onClear={() => { - onSearchTermSubmit("") + setCurrentTextAndQuery("") }} + setPage={setPage} />
diff --git a/frontends/ol-components/package.json b/frontends/ol-components/package.json index 87b9df5a3c..8942839161 100644 --- a/frontends/ol-components/package.json +++ b/frontends/ol-components/package.json @@ -26,7 +26,6 @@ "material-ui-popup-state": "^5.1.0", "ol-test-utilities": "0.0.0", "ol-utilities": "0.0.0", - "posthog-js": "^1.165.0", "react": "18.3.1", "react-router": "^6.22.2", "react-router-dom": "^6.22.2", diff --git a/frontends/ol-components/src/components/SearchInput/SearchInput.test.tsx b/frontends/ol-components/src/components/SearchInput/SearchInput.test.tsx index bac9a47464..6e00b8a3ec 100644 --- a/frontends/ol-components/src/components/SearchInput/SearchInput.test.tsx +++ b/frontends/ol-components/src/components/SearchInput/SearchInput.test.tsx @@ -66,7 +66,17 @@ describe("SearchInput", () => { it("Calls onSubmit when search is clicked", async () => { const { user, spies } = renderSearchInput({ value: "chemistry" }) await user.click(getSearchButton()) - expect(spies.onSubmit).toHaveBeenCalledWith(searchEvent("chemistry")) + expect(spies.onSubmit).toHaveBeenCalledWith(searchEvent("chemistry"), { + isEnter: false, + }) + }) + + it("Calls onSubmit when 'Enter' is pressed", async () => { + const { user, spies } = renderSearchInput({ value: "chemistry" }) + await user.type(getSearchInput(), "{enter}") + expect(spies.onSubmit).toHaveBeenCalledWith(searchEvent("chemistry"), { + isEnter: true, + }) }) it("Calls onClear clear is clicked", async () => { diff --git a/frontends/ol-components/src/components/SearchInput/SearchInput.tsx b/frontends/ol-components/src/components/SearchInput/SearchInput.tsx index 09e711f40e..3391e299dd 100644 --- a/frontends/ol-components/src/components/SearchInput/SearchInput.tsx +++ b/frontends/ol-components/src/components/SearchInput/SearchInput.tsx @@ -1,9 +1,8 @@ -import React, { useCallback } from "react" +import React from "react" import { RiSearch2Line, RiCloseLine } from "@remixicon/react" import { Input, AdornmentButton } from "../Input/Input" import type { InputProps } from "../Input/Input" import styled from "@emotion/styled" -import { usePostHog } from "posthog-js/react" const StyledInput = styled(Input)(({ theme }) => ({ boxShadow: "0px 8px 20px 0px rgba(120, 147, 172, 0.10)", @@ -34,7 +33,10 @@ export interface SearchSubmissionEvent { preventDefault: () => void } -type SearchSubmitHandler = (event: SearchSubmissionEvent) => void +type SearchSubmitHandler = ( + event: SearchSubmissionEvent, + opts?: { isEnter?: boolean }, +) => void interface SearchInputProps { className?: string @@ -54,35 +56,15 @@ const muiInputProps = { "aria-label": "Search for" } const SearchInput: React.FC = (props) => { const { onSubmit, value } = props - const posthog = usePostHog() - const { POSTHOG } = APP_SETTINGS + const event = { + target: { value }, + preventDefault: () => null, + } - const handleSubmit = useCallback( - ( - ev: - | React.SyntheticEvent - | React.SyntheticEvent, - isEnter: boolean = false, - ) => { - const event = { - target: { value }, - preventDefault: () => null, - } - if (!(!POSTHOG?.api_key || POSTHOG.api_key.length < 1)) { - posthog.capture("search_update", { isEnter: isEnter }) - } - onSubmit(event) - }, - [onSubmit, value, posthog, POSTHOG], - ) - const onInputKeyDown: React.KeyboardEventHandler = - useCallback( - (e) => { - if (e.key !== "Enter") return - handleSubmit(e, true) - }, - [handleSubmit], - ) + const onInputKeyDown: React.KeyboardEventHandler = (e) => { + if (e.key !== "Enter") return + onSubmit(event, { isEnter: true }) + } return ( = (props) => { onSubmit(event, { isEnter: false })} > diff --git a/frontends/ol-components/src/index.ts b/frontends/ol-components/src/index.ts index 9c988a0768..eb81aa4375 100644 --- a/frontends/ol-components/src/index.ts +++ b/frontends/ol-components/src/index.ts @@ -200,7 +200,10 @@ export * from "./constants/imgConfigs" export { Input, AdornmentButton } from "./components/Input/Input" export type { InputProps, AdornmentButtonProps } from "./components/Input/Input" export { SearchInput } from "./components/SearchInput/SearchInput" -export type { SearchInputProps } from "./components/SearchInput/SearchInput" +export type { + SearchInputProps, + SearchSubmissionEvent, +} from "./components/SearchInput/SearchInput" export { TextField } from "./components/TextField/TextField" export { SimpleSelect, diff --git a/yarn.lock b/yarn.lock index 6452e2d589..7fa390a739 100644 --- a/yarn.lock +++ b/yarn.lock @@ -15408,7 +15408,6 @@ __metadata: material-ui-popup-state: "npm:^5.1.0" ol-test-utilities: "npm:0.0.0" ol-utilities: "npm:0.0.0" - posthog-js: "npm:^1.165.0" prop-types: "npm:^15.8.1" react: "npm:18.3.1" react-router: "npm:^6.22.2" @@ -16578,17 +16577,6 @@ __metadata: languageName: node linkType: hard -"posthog-js@npm:^1.165.0": - version: 1.165.0 - resolution: "posthog-js@npm:1.165.0" - dependencies: - fflate: "npm:^0.4.8" - preact: "npm:^10.19.3" - web-vitals: "npm:^4.0.1" - checksum: 10/4a640b90af24ffb173b4d20f27aab572437c8641b1ff48ad23e98d593fa7e94e63e660a4ce967a18eaabaf5102ecaff8a258315b47d1916e79a7f1ec7ad3bc7d - languageName: node - linkType: hard - "preact@npm:^10.19.3": version: 10.23.1 resolution: "preact@npm:10.23.1" From 598fac23b98762a4cfa74a5a2d8f42ae10de36c0 Mon Sep 17 00:00:00 2001 From: James Kachel Date: Thu, 3 Oct 2024 12:20:18 -0500 Subject: [PATCH 47/48] Adds base infra for the Unified Ecommerce frontend (#1634) --- .../mit-learn/src/common/feature_flags.ts | 6 ++ frontends/mit-learn/src/common/urls.ts | 2 + .../EcommerceFeature/EcommerceFeature.tsx | 33 ++++++++++ .../pages/EcommercePages/CartPage.test.tsx | 65 +++++++++++++++++++ .../src/pages/EcommercePages/CartPage.tsx | 31 +++++++++ .../src/pages/EcommercePages/README.md | 9 +++ frontends/mit-learn/src/routes.tsx | 11 ++++ 7 files changed, 157 insertions(+) create mode 100644 frontends/mit-learn/src/common/feature_flags.ts create mode 100644 frontends/mit-learn/src/page-components/EcommerceFeature/EcommerceFeature.tsx create mode 100644 frontends/mit-learn/src/pages/EcommercePages/CartPage.test.tsx create mode 100644 frontends/mit-learn/src/pages/EcommercePages/CartPage.tsx create mode 100644 frontends/mit-learn/src/pages/EcommercePages/README.md diff --git a/frontends/mit-learn/src/common/feature_flags.ts b/frontends/mit-learn/src/common/feature_flags.ts new file mode 100644 index 0000000000..8d6e479d1e --- /dev/null +++ b/frontends/mit-learn/src/common/feature_flags.ts @@ -0,0 +1,6 @@ +// Feature flags for the app. These should correspond to the flag that's set up +// in PostHog. + +export enum FeatureFlags { + EnableEcommerce = "enable-ecommerce", +} diff --git a/frontends/mit-learn/src/common/urls.ts b/frontends/mit-learn/src/common/urls.ts index 8dad6174e8..363bced253 100644 --- a/frontends/mit-learn/src/common/urls.ts +++ b/frontends/mit-learn/src/common/urls.ts @@ -127,3 +127,5 @@ export const SEARCH_PROGRAM = querifiedSearchUrl({ export const SEARCH_LEARNING_MATERIAL = querifiedSearchUrl({ resource_category: "learning_material", }) + +export const ECOMMERCE_CART = "/cart/" as const diff --git a/frontends/mit-learn/src/page-components/EcommerceFeature/EcommerceFeature.tsx b/frontends/mit-learn/src/page-components/EcommerceFeature/EcommerceFeature.tsx new file mode 100644 index 0000000000..0cbb0a2edf --- /dev/null +++ b/frontends/mit-learn/src/page-components/EcommerceFeature/EcommerceFeature.tsx @@ -0,0 +1,33 @@ +import React from "react" +import { useFeatureFlagEnabled } from "posthog-js/react" +import { ForbiddenError } from "@/common/permissions" +import { FeatureFlags } from "@/common/feature_flags" + +type EcommerceFeatureProps = { + children: React.ReactNode +} + +/** + * Simple wrapper to standardize the feature flag check for ecommerce UI pages. + * If the flag is enabled, display the children; if not, throw a ForbiddenError + * like you'd get for an unauthenticated route. + * + * There's a PostHogFeature component that is provided but went this route + * because it seemed to be inconsistent - sometimes having the flag enabled + * resulted in it tossing to the error page. + * + * Set the feature flag here using the enum, and then make sure it's also + * defined in commmon/feature_flags too. + */ + +const EcommerceFeature: React.FC = ({ children }) => { + const ecommFlag = useFeatureFlagEnabled(FeatureFlags.EnableEcommerce) + + if (ecommFlag === false) { + throw new ForbiddenError("Not enabled.") + } + + return ecommFlag ? children : null +} + +export default EcommerceFeature diff --git a/frontends/mit-learn/src/pages/EcommercePages/CartPage.test.tsx b/frontends/mit-learn/src/pages/EcommercePages/CartPage.test.tsx new file mode 100644 index 0000000000..1a5eb127cc --- /dev/null +++ b/frontends/mit-learn/src/pages/EcommercePages/CartPage.test.tsx @@ -0,0 +1,65 @@ +import { renderTestApp, waitFor, setMockResponse } from "../../test-utils" +import { urls } from "api/test-utils" +import * as commonUrls from "@/common/urls" +import { Permissions } from "@/common/permissions" +import { login } from "@/common/urls" +import { useFeatureFlagEnabled } from "posthog-js/react" + +jest.mock("posthog-js/react") +const mockedUseFeatureFlagEnabled = jest.mocked(useFeatureFlagEnabled) + +const oldWindowLocation = window.location + +beforeAll(() => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + delete (window as any).location + + window.location = Object.defineProperties({} as Location, { + ...Object.getOwnPropertyDescriptors(oldWindowLocation), + assign: { + configurable: true, + value: jest.fn(), + }, + }) +}) + +afterAll(() => { + window.location = oldWindowLocation +}) + +describe("CartPage", () => { + ;["on", "off"].forEach((testCase: string) => { + test(`Renders when logged in and feature flag is ${testCase}`, async () => { + setMockResponse.get(urls.userMe.get(), { + [Permissions.Authenticated]: true, + }) + mockedUseFeatureFlagEnabled.mockReturnValue(testCase === "on") + + renderTestApp({ + url: commonUrls.ECOMMERCE_CART, + }) + await waitFor(() => { + testCase === "on" + ? expect(document.title).toBe("Shopping Cart | MIT Learn") + : expect(document.title).not.toBe("Shopping Cart | MIT Learn") + }) + }) + }) + + test("Sends to login page when logged out", async () => { + setMockResponse.get(urls.userMe.get(), { + [Permissions.Authenticated]: false, + }) + const expectedUrl = login({ + pathname: "/cart/", + }) + + renderTestApp({ + url: commonUrls.ECOMMERCE_CART, + }) + + await waitFor(() => { + expect(window.location.assign).toHaveBeenCalledWith(expectedUrl) + }) + }) +}) diff --git a/frontends/mit-learn/src/pages/EcommercePages/CartPage.tsx b/frontends/mit-learn/src/pages/EcommercePages/CartPage.tsx new file mode 100644 index 0000000000..59f9b94dd4 --- /dev/null +++ b/frontends/mit-learn/src/pages/EcommercePages/CartPage.tsx @@ -0,0 +1,31 @@ +import React from "react" +import { Breadcrumbs, Container, Typography } from "ol-components" +import EcommerceFeature from "@/page-components/EcommerceFeature/EcommerceFeature" +import MetaTags from "@/page-components/MetaTags/MetaTags" +import * as urls from "@/common/urls" + +const CartPage: React.FC = () => { + return ( + + + + + + + Shopping Cart + + + + The shopping cart layout should go here, if you're allowed to see + this. + + + + ) +} + +export default CartPage diff --git a/frontends/mit-learn/src/pages/EcommercePages/README.md b/frontends/mit-learn/src/pages/EcommercePages/README.md new file mode 100644 index 0000000000..878d3f0887 --- /dev/null +++ b/frontends/mit-learn/src/pages/EcommercePages/README.md @@ -0,0 +1,9 @@ +# Unified Ecommerce in MIT Learn + +The front end for the Unified Ecommerce system lives in MIT Learn. So, pages that exist here are designed to talk to Unified Ecommerce rather than to the Learn system. + +There's a few functional pieces here: + +- **Cart** - Displays the user's cart, and provides some additional functionality for that (item management, discount application, etc.) +- **Receipts** - Allows the user to display their order history and view receipts from their purchases, including historical ones from other systems. +- **Financial Assistance** - For learning resources that support it, the learner side of the financial assistance request system lives here. (Approvals do not.) diff --git a/frontends/mit-learn/src/routes.tsx b/frontends/mit-learn/src/routes.tsx index fb5b338066..0cef84e264 100644 --- a/frontends/mit-learn/src/routes.tsx +++ b/frontends/mit-learn/src/routes.tsx @@ -1,6 +1,7 @@ import React from "react" import { RouteObject, Outlet } from "react-router" import { ScrollRestoration } from "react-router-dom" + import HomePage from "@/pages/HomePage/HomePage" import RestrictedRoute from "@/components/RestrictedRoute/RestrictedRoute" import LearningPathListingPage from "@/pages/LearningPathListingPage/LearningPathListingPage" @@ -26,6 +27,7 @@ import DepartmentListingPage from "./pages/DepartmentListingPage/DepartmentListi import TopicsListingPage from "./pages/TopicListingPage/TopicsListingPage" import UnitsListingPage from "./pages/UnitsListingPage/UnitsListingPage" import OnboardingPage from "./pages/OnboardingPage/OnboardingPage" +import CartPage from "./pages/EcommercePages/CartPage" import { styled } from "ol-components" @@ -190,6 +192,15 @@ const routes: RouteObject[] = [ }, ], }, + { + element: , + children: [ + { + path: urls.ECOMMERCE_CART, + element: , + }, + ], + }, ], }, ] From 9deded50c4c9f48ab6d1bf39bd11022e67a1a93f Mon Sep 17 00:00:00 2001 From: Chris Chudzicki Date: Mon, 7 Oct 2024 10:24:31 -0400 Subject: [PATCH 48/48] Various post-merge bug fixes: - apply actionbuttonlink styles correctly - fix logo sizes - fix type errors - regenerate openapi spec - move ecommerce to nextjs --- .../CartPage}/CartPage.test.tsx | 45 +++--- .../EcommercePages/CartPage}/CartPage.tsx | 3 +- .../EcommercePages/CartPage}/README.md | 0 frontends/main/src/app/cart/page.tsx | 14 ++ .../components/MITLogoLink/MITLogoLink.tsx | 87 ++++++++---- .../EcommerceFeature/EcommerceFeature.tsx | 0 .../page-components/Footer/Footer.test.tsx | 4 +- .../src/page-components/Footer/Footer.tsx | 13 +- .../page-components/Header/Header.test.tsx | 2 +- .../src/page-components/Header/Header.tsx | 6 +- .../src/page-components/Header/UserMenu.tsx | 17 +-- .../src/components/Banner/Banner.stories.tsx | 6 +- .../src/components/Button/Button.stories.tsx | 69 +++++++-- .../src/components/Button/Button.tsx | 41 +++--- .../src/components/Dialog/Dialog.tsx | 7 +- .../components/TabButtons/TabButtonList.tsx | 3 +- openapi/specs/v1.yaml | 133 ++++++++++++++++++ 17 files changed, 325 insertions(+), 125 deletions(-) rename frontends/{mit-learn/src/pages/EcommercePages => main/src/app-pages/EcommercePages/CartPage}/CartPage.test.tsx (54%) rename frontends/{mit-learn/src/pages/EcommercePages => main/src/app-pages/EcommercePages/CartPage}/CartPage.tsx (88%) rename frontends/{mit-learn/src/pages/EcommercePages => main/src/app-pages/EcommercePages/CartPage}/README.md (100%) create mode 100644 frontends/main/src/app/cart/page.tsx rename frontends/{mit-learn => main}/src/page-components/EcommerceFeature/EcommerceFeature.tsx (100%) diff --git a/frontends/mit-learn/src/pages/EcommercePages/CartPage.test.tsx b/frontends/main/src/app-pages/EcommercePages/CartPage/CartPage.test.tsx similarity index 54% rename from frontends/mit-learn/src/pages/EcommercePages/CartPage.test.tsx rename to frontends/main/src/app-pages/EcommercePages/CartPage/CartPage.test.tsx index 1a5eb127cc..5cf414fc23 100644 --- a/frontends/mit-learn/src/pages/EcommercePages/CartPage.test.tsx +++ b/frontends/main/src/app-pages/EcommercePages/CartPage/CartPage.test.tsx @@ -1,9 +1,11 @@ -import { renderTestApp, waitFor, setMockResponse } from "../../test-utils" +import React from "react" +import { renderWithProviders, setMockResponse } from "@/test-utils" import { urls } from "api/test-utils" import * as commonUrls from "@/common/urls" -import { Permissions } from "@/common/permissions" -import { login } from "@/common/urls" +import { ForbiddenError, Permissions } from "@/common/permissions" import { useFeatureFlagEnabled } from "posthog-js/react" +import CartPage from "./CartPage" +import { allowConsoleErrors } from "ol-test-utilities" jest.mock("posthog-js/react") const mockedUseFeatureFlagEnabled = jest.mocked(useFeatureFlagEnabled) @@ -35,31 +37,18 @@ describe("CartPage", () => { }) mockedUseFeatureFlagEnabled.mockReturnValue(testCase === "on") - renderTestApp({ - url: commonUrls.ECOMMERCE_CART, - }) - await waitFor(() => { - testCase === "on" - ? expect(document.title).toBe("Shopping Cart | MIT Learn") - : expect(document.title).not.toBe("Shopping Cart | MIT Learn") - }) - }) - }) - - test("Sends to login page when logged out", async () => { - setMockResponse.get(urls.userMe.get(), { - [Permissions.Authenticated]: false, - }) - const expectedUrl = login({ - pathname: "/cart/", - }) - - renderTestApp({ - url: commonUrls.ECOMMERCE_CART, - }) - - await waitFor(() => { - expect(window.location.assign).toHaveBeenCalledWith(expectedUrl) + if (testCase === "off") { + allowConsoleErrors() + expect(() => + renderWithProviders(, { + url: commonUrls.ECOMMERCE_CART, + }), + ).toThrow(ForbiddenError) + } else { + renderWithProviders(, { + url: commonUrls.ECOMMERCE_CART, + }) + } }) }) }) diff --git a/frontends/mit-learn/src/pages/EcommercePages/CartPage.tsx b/frontends/main/src/app-pages/EcommercePages/CartPage/CartPage.tsx similarity index 88% rename from frontends/mit-learn/src/pages/EcommercePages/CartPage.tsx rename to frontends/main/src/app-pages/EcommercePages/CartPage/CartPage.tsx index 59f9b94dd4..00f56199e6 100644 --- a/frontends/mit-learn/src/pages/EcommercePages/CartPage.tsx +++ b/frontends/main/src/app-pages/EcommercePages/CartPage/CartPage.tsx @@ -1,14 +1,13 @@ +"use client" import React from "react" import { Breadcrumbs, Container, Typography } from "ol-components" import EcommerceFeature from "@/page-components/EcommerceFeature/EcommerceFeature" -import MetaTags from "@/page-components/MetaTags/MetaTags" import * as urls from "@/common/urls" const CartPage: React.FC = () => { return ( - { + return +} + +export default Page diff --git a/frontends/main/src/components/MITLogoLink/MITLogoLink.tsx b/frontends/main/src/components/MITLogoLink/MITLogoLink.tsx index dc80c8251a..0ca1ce6e23 100644 --- a/frontends/main/src/components/MITLogoLink/MITLogoLink.tsx +++ b/frontends/main/src/components/MITLogoLink/MITLogoLink.tsx @@ -1,34 +1,71 @@ -import React, { HTMLAttributeAnchorTarget } from "react" +import React from "react" import Image from "next/image" import Link from "next/link" -import defaultLogo from "@/public/images/mit-logo-black.svg" +import mitLogoBlack from "@/public/images/mit-logo-black.svg" +import whiteLogoWhite from "@/public/images/mit-logo-white.svg" +import learnLogo from "@/public/images/mit-learn-logo.svg" +import { styled } from "ol-components" interface Props { - href?: string + logo: "mit_white" | "mit_black" | "learn" className?: string - logo?: string - alt?: string - target?: HTMLAttributeAnchorTarget } -const MITLogoLink: React.FC = ({ - href, - logo, - alt = "MIT Logo", - className, - target, -}) => ( - - - -) +const StyledImage = styled(Image)({ + /** + * Needs display: block because otherwise the image is inline, which complicates + * parent container height calculations. + * + * See https://stackoverflow.com/a/11126701/2747370 + */ + display: "block", + width: "auto", +}) +const linkProps = { + learn: { + href: "/", + title: "MIT Learn Homepage", + }, + mit_black: { + href: "https://mit.edu/", + title: "MIT Homepage", + target: "_blank", + }, + mit_white: { + href: "https://mit.edu/", + title: "MIT Homepage", + target: "_blank", + }, +} +const imageProps = { + learn: { + src: learnLogo, + }, + mit_black: { + src: mitLogoBlack, + }, + mit_white: { + src: whiteLogoWhite, + }, +} + +/** + * Used for MIT logo variations. + * + * To size the logo, specify at least the `height` of `img` via the parent's className. + */ +const MITLogoLink: React.FC = ({ logo, className }) => { + return ( + + + + ) +} export default MITLogoLink diff --git a/frontends/mit-learn/src/page-components/EcommerceFeature/EcommerceFeature.tsx b/frontends/main/src/page-components/EcommerceFeature/EcommerceFeature.tsx similarity index 100% rename from frontends/mit-learn/src/page-components/EcommerceFeature/EcommerceFeature.tsx rename to frontends/main/src/page-components/EcommerceFeature/EcommerceFeature.tsx diff --git a/frontends/main/src/page-components/Footer/Footer.test.tsx b/frontends/main/src/page-components/Footer/Footer.test.tsx index 51dc3eee5a..82c2391429 100644 --- a/frontends/main/src/page-components/Footer/Footer.test.tsx +++ b/frontends/main/src/page-components/Footer/Footer.test.tsx @@ -16,7 +16,7 @@ describe("Footer", () => { } const expectedLinks: Links = { // key is blank here because the link is an image - "": "https://www.mit.edu/", + "": "https://mit.edu/", Home: urls.HOME, "About Us": urls.ABOUT, Accessibility: urls.ACCESSIBILITY, @@ -24,7 +24,6 @@ describe("Footer", () => { "Contact Us": urls.CONTACT, } const footer = screen.getByRole("contentinfo") - const icon = screen.getByAltText("MIT Logo") const address = screen.getByTestId("footer-address") const links = screen.getAllByRole("link") const copyright = screen.getByText( @@ -32,7 +31,6 @@ describe("Footer", () => { ) expect(footer).toBeInTheDocument() - expect(icon).toBeInTheDocument() expect(address).toHaveTextContent("Massachusetts Institute of Technology") expect(address).toHaveTextContent("77 Massachusetts Avenue") expect(address).toHaveTextContent("Cambridge, MA 02139") diff --git a/frontends/main/src/page-components/Footer/Footer.tsx b/frontends/main/src/page-components/Footer/Footer.tsx index c1f744b000..473f4a0db5 100644 --- a/frontends/main/src/page-components/Footer/Footer.tsx +++ b/frontends/main/src/page-components/Footer/Footer.tsx @@ -47,12 +47,11 @@ const FooterLeftContainer = styled.div(({ theme }) => ({ })) const FooterLogo = styled(MITLogoLink)(({ theme }) => ({ - position: "relative", - width: "95px", - height: "48px", - [theme.breakpoints.down("md")]: { - width: "80px", - height: "40px", + img: { + height: "48px", + [theme.breakpoints.down("md")]: { + height: "40px", + }, }, })) @@ -147,7 +146,7 @@ const Footer: FunctionComponent = () => { - + Massachusetts Institute of Technology
diff --git a/frontends/main/src/page-components/Header/Header.test.tsx b/frontends/main/src/page-components/Header/Header.test.tsx index ac18cf1564..04c5f20f5a 100644 --- a/frontends/main/src/page-components/Header/Header.test.tsx +++ b/frontends/main/src/page-components/Header/Header.test.tsx @@ -16,7 +16,7 @@ describe("Header", () => { setMockResponse.get(urls.userMe.get(), {}) renderWithProviders(
) const header = screen.getByRole("banner") - within(header).getAllByTitle("Link to Homepage", { exact: false }) + within(header).getAllByTitle("MIT Learn Homepage") }) }) diff --git a/frontends/main/src/page-components/Header/Header.tsx b/frontends/main/src/page-components/Header/Header.tsx index e0bef0cd4b..bca8a52122 100644 --- a/frontends/main/src/page-components/Header/Header.tsx +++ b/frontends/main/src/page-components/Header/Header.tsx @@ -43,7 +43,6 @@ import { SEARCH_LEARNING_MATERIAL, } from "@/common/urls" import { useUserMe } from "api/hooks/user" -import learnLogo from "@/public/images/mit-learn-logo.svg" const Bar = styled(AppBar)(({ theme }) => ({ padding: "16px 8px", @@ -91,6 +90,7 @@ const StyledToolbar = styled(Toolbar)({ const StyledMITLogoLink = styled(MITLogoLink)(({ theme }) => ({ img: { height: "24px", + width: "auto", [theme.breakpoints.down("sm")]: { height: "16px", }, @@ -277,7 +277,7 @@ const Header: FunctionComponent = () => { - + { - + diff --git a/frontends/main/src/page-components/Header/UserMenu.tsx b/frontends/main/src/page-components/Header/UserMenu.tsx index ba11c27084..b4d3c9891c 100644 --- a/frontends/main/src/page-components/Header/UserMenu.tsx +++ b/frontends/main/src/page-components/Header/UserMenu.tsx @@ -18,7 +18,6 @@ import { import { useUserMe, User } from "api/hooks/user" import { usePathname, useSearchParams } from "next/navigation" import MITLogoLink from "@/components/MITLogoLink/MITLogoLink" -import whiteLogo from "@/public/images/mit-logo-white.svg" const FlexContainer = styled.div({ display: "flex", @@ -128,16 +127,6 @@ type UserMenuProps = { variant?: DeviceType } -const MITHomeLink: React.FC = () => { - return ( - - ) -} - const UserMenu: React.FC = ({ variant }) => { const [visible, setVisible] = useState(false) @@ -223,7 +212,7 @@ const UserMenu: React.FC = ({ variant }) => { } /> - + ) } else { @@ -240,7 +229,7 @@ const UserMenu: React.FC = ({ variant }) => { > Log In - + ) : ( "" @@ -257,7 +246,7 @@ const UserMenu: React.FC = ({ variant }) => { > - + ) : ( "" diff --git a/frontends/ol-components/src/components/Banner/Banner.stories.tsx b/frontends/ol-components/src/components/Banner/Banner.stories.tsx index c3bcad2d04..6a5c12b4b9 100644 --- a/frontends/ol-components/src/components/Banner/Banner.stories.tsx +++ b/frontends/ol-components/src/components/Banner/Banner.stories.tsx @@ -71,11 +71,7 @@ export const logoBannerWithExtras: Story = { style={{ height: "37px", filter: "saturate(0%) invert(100%)" }} /> } - extraHeader={ - - } + extraHeader={} extraRight={
Extra Content diff --git a/frontends/ol-components/src/components/Button/Button.stories.tsx b/frontends/ol-components/src/components/Button/Button.stories.tsx index aa147d73ae..499e408c33 100644 --- a/frontends/ol-components/src/components/Button/Button.stories.tsx +++ b/frontends/ol-components/src/components/Button/Button.stories.tsx @@ -1,6 +1,6 @@ import React from "react" import type { Meta, StoryObj } from "@storybook/react" -import { Button, ActionButton, ButtonLink } from "./Button" +import { Button, ActionButton, ButtonLink, ActionButtonLink } from "./Button" import Grid from "@mui/material/Grid" import Stack from "@mui/material/Stack" import { @@ -69,7 +69,6 @@ type Story = StoryObj export const Simple: Story = { args: { variant: "primary", - color: "action", }, render: (args) => , } @@ -324,17 +323,61 @@ export const ActionButtonsShowcase: Story = { {SIZES.map((size) => ( - {Object.entries(ICONS).map(([key, icon]) => ( - - {icon} - - ))} + {Object.entries(ICONS) + .filter(([_key, icon]) => icon) + .map(([key, icon]) => ( + + {icon} + + ))} + + ))} + + )), + )} + + ), +} + +export const ActionButtonLinkShowcase: StoryObj = { + render: (args) => ( + <> + {VARIANTS.flatMap((variant) => + EDGES.flatMap((edge) => ( + +
+              variant={variant}
+              
+ edge={edge} +
+ {SIZES.map((size) => ( + + {Object.entries(ICONS) + .filter(([_key, icon]) => icon) + .map(([key, icon]) => ( + + {icon} + + ))} ))}
diff --git a/frontends/ol-components/src/components/Button/Button.tsx b/frontends/ol-components/src/components/Button/Button.tsx index 5382ce4620..42ab997604 100644 --- a/frontends/ol-components/src/components/Button/Button.tsx +++ b/frontends/ol-components/src/components/Button/Button.tsx @@ -1,5 +1,6 @@ import React from "react" import styled from "@emotion/styled" +import { css } from "@emotion/react" import { pxToRem } from "../ThemeProvider/typography" import tinycolor from "tinycolor2" import Link from "next/link" @@ -30,6 +31,7 @@ type ButtonStyleProps = { * - small -> small */ responsive?: boolean + color?: "secondary" } const styleProps: Record = { @@ -39,17 +41,19 @@ const styleProps: Record = { startIcon: true, endIcon: true, responsive: true, + color: true, } satisfies Record const shouldForwardProp = (prop: string) => !styleProps[prop] -const defaultProps: Required> = - { - variant: "primary", - size: "medium", - edge: "rounded", - responsive: false, - } +const defaultProps: Required< + Omit +> = { + variant: "primary", + size: "medium", + edge: "rounded", + responsive: false, +} const borderWidths = { small: 1, @@ -83,17 +87,14 @@ const sizeStyles = (size: ButtonSize, hasBorder: boolean, theme: Theme) => { ] } -const ButtonStyled = styled("button", { shouldForwardProp })(( - props, -) => { +const buttonStyles = (props: ButtonStyleProps & { theme: Theme }) => { const { size, variant, edge, theme, color, responsive } = { ...defaultProps, ...props, } const { colors } = theme.custom const hasBorder = variant === "secondary" - - return [ + return css([ { color: theme.palette.text.primary, textAlign: "center", @@ -227,8 +228,12 @@ const ButtonStyled = styled("button", { shouldForwardProp })(( backgroundColor: theme.custom.colors.lightGray1, }, }, - ] -}) + ]) +} + +const ButtonStyled = styled("button", { shouldForwardProp })( + (props) => buttonStyles(props), +) const IconContainer = styled.span<{ side: "start" | "end"; size: ButtonSize }>( ({ size, side }) => [ @@ -357,13 +362,15 @@ const actionStyles = (size: ButtonSize) => { */ const ActionButton = styled( React.forwardRef((props, ref) => ( - +