From 6d25db1b65523ff12e08c8292384a555d77b5f1c Mon Sep 17 00:00:00 2001 From: James Kachel Date: Fri, 15 Aug 2025 18:00:53 -0500 Subject: [PATCH 1/4] B2B Provisioning: Add interstitial page to process redemption code retrieval (#2422) * Adds (really pretty ugly) interstitial page, routing, and cobbles together a sample API hook The hook does hit the right endpoint but it's a stand-in until the API PR gets merged. * Adding more tests, fixing a few things, making the design a little bit less terrible * Re-enable the redirect (disabled to grab a screenshot) * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * formatting * update api client and use the new attach function, also update interstitial page to use it and the nextjs redirect function instead of setting window.location directly * fix upstream issue with course with course runs return type changing * More upstream fixes * use useEffect * put attach in dependencies --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Carey Gumaer --- frontends/api/package.json | 2 +- .../src/mitxonline/hooks/courses/queries.ts | 9 ++-- .../mitxonline/hooks/organizations/index.ts | 4 +- .../mitxonline/hooks/organizations/queries.ts | 27 ++++++++-- .../test-utils/factories/courses.ts | 10 ++-- .../api/src/mitxonline/test-utils/urls.ts | 5 ++ frontends/main/package.json | 2 +- .../B2BAttachPage/B2BAttachPage.test.tsx | 50 +++++++++++++++++++ .../app-pages/B2BAttachPage/B2BAttachPage.tsx | 50 +++++++++++++++++++ .../CoursewareDisplay/transform.ts | 6 +-- frontends/main/src/app/attach/[code]/page.tsx | 26 ++++++++++ frontends/main/src/common/urls.ts | 4 ++ yarn.lock | 12 ++--- 13 files changed, 182 insertions(+), 25 deletions(-) create mode 100644 frontends/main/src/app-pages/B2BAttachPage/B2BAttachPage.test.tsx create mode 100644 frontends/main/src/app-pages/B2BAttachPage/B2BAttachPage.tsx create mode 100644 frontends/main/src/app/attach/[code]/page.tsx diff --git a/frontends/api/package.json b/frontends/api/package.json index 63276ce840..9a85853dbc 100644 --- a/frontends/api/package.json +++ b/frontends/api/package.json @@ -30,7 +30,7 @@ "ol-test-utilities": "0.0.0" }, "dependencies": { - "@mitodl/mitxonline-api-axios": "2025.8.6", + "@mitodl/mitxonline-api-axios": "2025.8.12", "@tanstack/react-query": "^5.66.0", "axios": "^1.6.3" } diff --git a/frontends/api/src/mitxonline/hooks/courses/queries.ts b/frontends/api/src/mitxonline/hooks/courses/queries.ts index 7dd9dfa36e..33c6056f79 100644 --- a/frontends/api/src/mitxonline/hooks/courses/queries.ts +++ b/frontends/api/src/mitxonline/hooks/courses/queries.ts @@ -1,7 +1,7 @@ import { queryOptions } from "@tanstack/react-query" import type { CoursesApiApiV2CoursesListRequest, - PaginatedV2CourseWithCourseRunsList, + PaginatedCourseWithCourseRunsSerializerV2List, } from "@mitodl/mitxonline-api-axios/v2" import { coursesApi } from "../../clients" @@ -18,9 +18,10 @@ const coursesQueries = { coursesList: (opts?: CoursesApiApiV2CoursesListRequest) => queryOptions({ queryKey: coursesKeys.coursesList(opts), - queryFn: async (): Promise => { - return coursesApi.apiV2CoursesList(opts).then((res) => res.data) - }, + queryFn: + async (): Promise => { + return coursesApi.apiV2CoursesList(opts).then((res) => res.data) + }, }), } diff --git a/frontends/api/src/mitxonline/hooks/organizations/index.ts b/frontends/api/src/mitxonline/hooks/organizations/index.ts index 61db519596..e042776f5e 100644 --- a/frontends/api/src/mitxonline/hooks/organizations/index.ts +++ b/frontends/api/src/mitxonline/hooks/organizations/index.ts @@ -1,3 +1,3 @@ -import { organizationQueries } from "./queries" +import { organizationQueries, useB2BAttachMutation } from "./queries" -export { organizationQueries } +export { organizationQueries, useB2BAttachMutation } diff --git a/frontends/api/src/mitxonline/hooks/organizations/queries.ts b/frontends/api/src/mitxonline/hooks/organizations/queries.ts index c1bcc0d49d..e7ef845ec9 100644 --- a/frontends/api/src/mitxonline/hooks/organizations/queries.ts +++ b/frontends/api/src/mitxonline/hooks/organizations/queries.ts @@ -1,7 +1,14 @@ -import { B2bApiB2bOrganizationsRetrieveRequest } from "@mitodl/mitxonline-api-axios/v0" -import { OrganizationPage } from "@mitodl/mitxonline-api-axios/v2" -import { queryOptions } from "@tanstack/react-query" +import { + queryOptions, + useMutation, + useQueryClient, +} from "@tanstack/react-query" import { b2bApi } from "../../clients" +import { + OrganizationPage, + B2bApiB2bAttachCreateRequest, + B2bApiB2bOrganizationsRetrieveRequest, +} from "@mitodl/mitxonline-api-axios/v2" const organizationKeys = { root: ["mitxonline", "organizations"], @@ -22,4 +29,16 @@ const organizationQueries = { }), } -export { organizationQueries, organizationKeys } +const useB2BAttachMutation = (opts: B2bApiB2bAttachCreateRequest) => { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: () => b2bApi.b2bAttachCreate(opts), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: organizationKeys.organizationsRetrieve(), + }) + }, + }) +} + +export { organizationQueries, organizationKeys, useB2BAttachMutation } diff --git a/frontends/api/src/mitxonline/test-utils/factories/courses.ts b/frontends/api/src/mitxonline/test-utils/factories/courses.ts index 6e858175f9..adc81659c8 100644 --- a/frontends/api/src/mitxonline/test-utils/factories/courses.ts +++ b/frontends/api/src/mitxonline/test-utils/factories/courses.ts @@ -1,14 +1,16 @@ import { mergeOverrides, makePaginatedFactory } from "ol-test-utilities" import type { PartialFactory } from "ol-test-utilities" -import type { V2CourseWithCourseRuns } from "@mitodl/mitxonline-api-axios/v2" +import type { CourseWithCourseRunsSerializerV2 } from "@mitodl/mitxonline-api-axios/v2" import { faker } from "@faker-js/faker/locale/en" import { UniqueEnforcer } from "enforce-unique" const uniqueCourseId = new UniqueEnforcer() const uniqueCourseRunId = new UniqueEnforcer() -const course: PartialFactory = (overrides = {}) => { - const defaults: V2CourseWithCourseRuns = { +const course: PartialFactory = ( + overrides = {}, +) => { + const defaults: CourseWithCourseRunsSerializerV2 = { id: uniqueCourseId.enforce(() => faker.number.int()), title: faker.lorem.words(3), readable_id: faker.lorem.slug(), @@ -86,7 +88,7 @@ const course: PartialFactory = (overrides = {}) => { ingest_content_files_for_ai: faker.datatype.boolean(), } - return mergeOverrides(defaults, overrides) + return mergeOverrides(defaults, overrides) } const courses = makePaginatedFactory(course) diff --git a/frontends/api/src/mitxonline/test-utils/urls.ts b/frontends/api/src/mitxonline/test-utils/urls.ts index a31f94742c..129e030080 100644 --- a/frontends/api/src/mitxonline/test-utils/urls.ts +++ b/frontends/api/src/mitxonline/test-utils/urls.ts @@ -46,8 +46,13 @@ const organization = { `${API_BASE_URL}/api/v0/b2b/organizations/${organizationSlug}/`, } +const b2bAttach = { + b2bAttachView: (code: string) => `${API_BASE_URL}/api/v0/b2b/attach/${code}/`, +} + export { b2b, + b2bAttach, currentUser, enrollment, programs, diff --git a/frontends/main/package.json b/frontends/main/package.json index 6c0733ab08..a46ae1ccfd 100644 --- a/frontends/main/package.json +++ b/frontends/main/package.json @@ -14,7 +14,7 @@ "@emotion/cache": "^11.13.1", "@emotion/styled": "^11.11.0", "@mitodl/course-search-utils": "3.3.2", - "@mitodl/mitxonline-api-axios": "2025.8.6", + "@mitodl/mitxonline-api-axios": "2025.8.12", "@mitodl/smoot-design": "^6.10.0", "@next/bundle-analyzer": "^14.2.15", "@remixicon/react": "^4.2.0", diff --git a/frontends/main/src/app-pages/B2BAttachPage/B2BAttachPage.test.tsx b/frontends/main/src/app-pages/B2BAttachPage/B2BAttachPage.test.tsx new file mode 100644 index 0000000000..de25cbd0bb --- /dev/null +++ b/frontends/main/src/app-pages/B2BAttachPage/B2BAttachPage.test.tsx @@ -0,0 +1,50 @@ +import React from "react" +import { renderWithProviders, setMockResponse, waitFor } from "@/test-utils" +import { urls } from "api/test-utils" +import { urls as b2bUrls } from "api/mitxonline-test-utils" +import * as commonUrls from "@/common/urls" +import { Permission } from "api/hooks/user" +import B2BAttachPage from "./B2BAttachPage" +import { redirect } from "next/navigation" + +// Mock Next.js redirect function +jest.mock("next/navigation", () => ({ + redirect: jest.fn(), +})) + +const mockRedirect = jest.mocked(redirect) + +describe("B2BAttachPage", () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + test("Renders when logged in", async () => { + setMockResponse.get(urls.userMe.get(), { + [Permission.Authenticated]: true, + }) + + setMockResponse.post(b2bUrls.b2bAttach.b2bAttachView("test-code"), []) + + renderWithProviders(, { + url: commonUrls.B2B_ATTACH_VIEW, + }) + }) + + test("Redirects to dashboard on successful attachment", async () => { + setMockResponse.get(urls.userMe.get(), { + [Permission.Authenticated]: true, + }) + + setMockResponse.post(b2bUrls.b2bAttach.b2bAttachView("test-code"), []) + + renderWithProviders(, { + url: commonUrls.B2B_ATTACH_VIEW, + }) + + // Wait for the mutation to complete and verify redirect was called + await waitFor(() => { + expect(mockRedirect).toHaveBeenCalledWith(commonUrls.DASHBOARD_HOME) + }) + }) +}) diff --git a/frontends/main/src/app-pages/B2BAttachPage/B2BAttachPage.tsx b/frontends/main/src/app-pages/B2BAttachPage/B2BAttachPage.tsx new file mode 100644 index 0000000000..1ac8b19ab7 --- /dev/null +++ b/frontends/main/src/app-pages/B2BAttachPage/B2BAttachPage.tsx @@ -0,0 +1,50 @@ +"use client" +import React from "react" +import { redirect } from "next/navigation" +import { styled, Breadcrumbs, Container, Typography } from "ol-components" +import * as urls from "@/common/urls" +import { useB2BAttachMutation } from "api/mitxonline-hooks/organizations" + +type B2BAttachPageProps = { + code: string +} + +const InterstitialMessage = styled(Typography)(({ theme }) => ({ + ...theme.typography.body1, + textAlign: "center", +})) + +const B2BAttachPage: React.FC = ({ code }) => { + const { + mutate: attach, + isSuccess, + isPending, + } = useB2BAttachMutation({ + enrollment_code: code, + }) + + React.useEffect(() => { + attach?.() + }, [attach]) + + React.useEffect(() => { + if (isSuccess) { + redirect(urls.DASHBOARD_HOME) + } + }, [isSuccess]) + + return ( + + + {isPending && ( + Validating code "{code}"... + )} + + ) +} + +export default B2BAttachPage diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/transform.ts b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/transform.ts index 225f6c0a4d..0065d5eda5 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/transform.ts +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/transform.ts @@ -6,7 +6,7 @@ import { CourseRunEnrollment, - V2CourseWithCourseRuns, + CourseWithCourseRunsSerializerV2, V2Program, V2ProgramCollection, } from "@mitodl/mitxonline-api-axios/v2" @@ -72,7 +72,7 @@ const mitxonlineEnrollments = (data: CourseRunEnrollment[]) => data.map((course) => mitxonlineEnrollment(course)) const mitxonlineUnenrolledCourse = ( - course: V2CourseWithCourseRuns, + course: CourseWithCourseRunsSerializerV2, ): DashboardCourse => { const run = course.courseruns.find((run) => run.id === course.next_run_id) return { @@ -98,7 +98,7 @@ const mitxonlineUnenrolledCourse = ( } const mitxonlineCourses = (raw: { - courses: V2CourseWithCourseRuns[] + courses: CourseWithCourseRunsSerializerV2[] enrollments: CourseRunEnrollment[] }) => { const enrollmentsByCourseId = groupBy( diff --git a/frontends/main/src/app/attach/[code]/page.tsx b/frontends/main/src/app/attach/[code]/page.tsx new file mode 100644 index 0000000000..dec90dcea2 --- /dev/null +++ b/frontends/main/src/app/attach/[code]/page.tsx @@ -0,0 +1,26 @@ +import React from "react" +import { Metadata } from "next" +import { standardizeMetadata } from "@/common/metadata" +import invariant from "tiny-invariant" +import { Permission } from "api/hooks/user" +import RestrictedRoute from "@/components/RestrictedRoute/RestrictedRoute" +import type { PageParams } from "@/app/types" +import B2BAttachPage from "@/app-pages/B2BAttachPage/B2BAttachPage" + +export const metadata: Metadata = standardizeMetadata({ + title: "Use Enrollment Code", +}) + +const Page: React.FC> = async ({ + params, +}) => { + const resolved = await params + invariant(resolved?.code, "code is required") + return ( + + + + ) +} + +export default Page diff --git a/frontends/main/src/common/urls.ts b/frontends/main/src/common/urls.ts index 1937457001..56f8b9092f 100644 --- a/frontends/main/src/common/urls.ts +++ b/frontends/main/src/common/urls.ts @@ -167,3 +167,7 @@ export const SEARCH_LEARNING_MATERIAL = querifiedSearchUrl({ }) export const ECOMMERCE_CART = "/cart/" as const + +export const B2B_ATTACH_VIEW = "/attach/[code]" +export const b2bAttachView = (code: string) => + generatePath(B2B_ATTACH_VIEW, { code: code }) diff --git a/yarn.lock b/yarn.lock index cfedc860c3..d080767835 100755 --- a/yarn.lock +++ b/yarn.lock @@ -3018,13 +3018,13 @@ __metadata: languageName: node linkType: hard -"@mitodl/mitxonline-api-axios@npm:2025.8.6": - version: 2025.8.6 - resolution: "@mitodl/mitxonline-api-axios@npm:2025.8.6" +"@mitodl/mitxonline-api-axios@npm:2025.8.12": + version: 2025.8.12 + resolution: "@mitodl/mitxonline-api-axios@npm:2025.8.12" dependencies: "@types/node": "npm:^20.11.19" axios: "npm:^1.6.5" - checksum: 10/6e20c0ff75a3700a39e16b6b88bf6fba1d682a8abf1d7dbbd369d5072530457ec535c4bc501933a0fb0602ea489cf2f8549d8806180040edf2ba1f0a4360ae84 + checksum: 10/5d2a0874c0fc251fe32218d0aeadc2b1134e61209ffeb2fb6c767676bb402a1cbcc7b7d00371419b8e4577a0119cac50d7f6799a64f8a4cf8a9c2da92b00fc39 languageName: node linkType: hard @@ -7363,7 +7363,7 @@ __metadata: resolution: "api@workspace:frontends/api" dependencies: "@faker-js/faker": "npm:^9.9.0" - "@mitodl/mitxonline-api-axios": "npm:2025.8.6" + "@mitodl/mitxonline-api-axios": "npm:2025.8.12" "@tanstack/react-query": "npm:^5.66.0" "@testing-library/react": "npm:^16.1.0" axios: "npm:^1.6.3" @@ -13950,7 +13950,7 @@ __metadata: "@emotion/styled": "npm:^11.11.0" "@faker-js/faker": "npm:^9.9.0" "@mitodl/course-search-utils": "npm:3.3.2" - "@mitodl/mitxonline-api-axios": "npm:2025.8.6" + "@mitodl/mitxonline-api-axios": "npm:2025.8.12" "@mitodl/smoot-design": "npm:^6.10.0" "@next/bundle-analyzer": "npm:^14.2.15" "@remixicon/react": "npm:^4.2.0" From b71ede23754f559402a18f8c123c1ace6abda837 Mon Sep 17 00:00:00 2001 From: Anastasia Beglova Date: Mon, 18 Aug 2025 09:56:46 -0400 Subject: [PATCH 2/4] fix popular search (#2436) --- learning_resources/etl/posthog.py | 12 ++++++- learning_resources/etl/posthog_test.py | 36 ++++++++++++++----- .../commands/load_posthog_lrd_view_events.py | 5 +-- learning_resources/models.py | 4 +-- learning_resources/tasks.py | 1 + learning_resources_search/constants.py | 1 + 6 files changed, 45 insertions(+), 14 deletions(-) diff --git a/learning_resources/etl/posthog.py b/learning_resources/etl/posthog.py index 1f0f633850..38c131cebc 100644 --- a/learning_resources/etl/posthog.py +++ b/learning_resources/etl/posthog.py @@ -14,6 +14,7 @@ from learning_resources.exceptions import PostHogAuthenticationError, PostHogQueryError from learning_resources.models import LearningResource, LearningResourceViewEvent +from learning_resources.utils import resource_upserted_actions log = logging.getLogger(__name__) @@ -275,4 +276,13 @@ def load_posthog_lrd_view_events( List of LearningResourceViewEvent """ - return [load_posthog_lrd_view_event(event) for event in events] + events = [load_posthog_lrd_view_event(event) for event in events] + learning_resource_ids = { + event.learning_resource_id for event in events if event is not None + } + + for resource_id in learning_resource_ids: + learning_resource = LearningResource.objects.get(id=resource_id) + resource_upserted_actions(learning_resource, percolate=False) + + return events diff --git a/learning_resources/etl/posthog_test.py b/learning_resources/etl/posthog_test.py index d3f5ecbe56..ff29c5ee1c 100644 --- a/learning_resources/etl/posthog_test.py +++ b/learning_resources/etl/posthog_test.py @@ -10,13 +10,14 @@ from faker import Faker from learning_resources.etl import posthog -from learning_resources.models import LearningResourceViewEvent +from learning_resources.factories import LearningResourceFactory +from learning_resources.models import LearningResource, LearningResourceViewEvent from main.test_utils import MockResponse fake = Faker() -def generate_fake_posthog_lr_properties(): +def generate_fake_posthog_lr_properties(learning_resource): """ Generate a fake set of properties for a PostHog event. @@ -27,16 +28,16 @@ def generate_fake_posthog_lr_properties(): return json.dumps( { - "resourceType": fake.word(ext_word_list=["course", "program", "video"]), + "resourceType": learning_resource.resource_type, "platformCode": random.randrange(0, 9999), # noqa: S311 - "resourceId": random.randrange(0, 9999), # noqa: S311 + "resourceId": learning_resource.id, "readableId": str(uuid.uuid4()), "event_date": fake.date_time().isoformat(), } ) -def generate_fake_posthog_query_event(**kwargs): +def generate_fake_posthog_query_event(learning_resource=None, **kwargs): """ Generate a fake PostHog query event. @@ -47,11 +48,14 @@ def generate_fake_posthog_query_event(**kwargs): """ # datetimes here are all naive because this is what PostHog returns. - + if learning_resource is None: + learning_resource = LearningResourceFactory.create() return [ kwargs.get("uuid", str(uuid.uuid4())), kwargs.get("event", ""), - kwargs.get("properties", generate_fake_posthog_lr_properties()), + kwargs.get( + "properties", generate_fake_posthog_lr_properties(learning_resource) + ), kwargs.get("timestamp", datetime.now().isoformat()), # noqa: DTZ005 kwargs.get("distinct_id", ""), kwargs.get("elements_chain", ""), @@ -68,6 +72,7 @@ def generate_fake_posthog_query_event(**kwargs): def generate_hogql_query_result(result_count: int = 5): """Return a faked-out HogQL result.""" + learning_resource = LearningResourceFactory.create() return { "clickhouse": "", @@ -98,7 +103,10 @@ def generate_hogql_query_result(result_count: int = 5): }, "offset": None, "query": None, - "results": [generate_fake_posthog_query_event() for _ in range(result_count)], + "results": [ + generate_fake_posthog_query_event(learning_resource) + for _ in range(result_count) + ], "timings": [], "types": [], } @@ -111,6 +119,7 @@ def hogql_query_result(): return generate_hogql_query_result() +@pytest.mark.django_db @pytest.mark.parametrize( "skip_setting", [[None], ["ph_api_key"], ["ph_root_endpoint"], ["ph_project_id"]] ) @@ -245,6 +254,10 @@ def load_posthog_lrd_view_events(mocker): mocker.patch("requests.post", side_effect=api_call_results) + upsert_mock = mocker.patch( + "learning_resources.etl.posthog.resource_upserted_actions", + autospec=True, + ) posthog_events = posthog.posthog_extract_lrd_view_events() lr_events = posthog.posthog_transform_lrd_view_events(posthog_events) @@ -252,3 +265,10 @@ def load_posthog_lrd_view_events(mocker): stored_events = load_posthog_lrd_view_events(lr_events) assert LearningResourceViewEvent.objects.count() == len(stored_events) + learning_resource_ids = [ + event.learning_resource_id for event in stored_events if event is not None + ] + learning_resource_ids = set(learning_resource_ids) + for resource_id in learning_resource_ids: + learning_resource = LearningResource.objects.get(id=resource_id) + upsert_mock.assert_any_call(learning_resource, percolate=False) diff --git a/learning_resources/management/commands/load_posthog_lrd_view_events.py b/learning_resources/management/commands/load_posthog_lrd_view_events.py index 6f7db3c49c..a282dc1ee4 100644 --- a/learning_resources/management/commands/load_posthog_lrd_view_events.py +++ b/learning_resources/management/commands/load_posthog_lrd_view_events.py @@ -2,8 +2,8 @@ from django.core.management import BaseCommand -from learning_resources.etl.pipelines import posthog_etl from learning_resources.models import LearningResourceViewEvent +from learning_resources.tasks import get_learning_resource_views class Command(BaseCommand): @@ -16,7 +16,8 @@ def handle(self, *args, **kwargs): # noqa: ARG002 self.stdout.write("Running the ETL pipeline...") - posthog_etl() + task = get_learning_resource_views.delay() + task.get() ev_count = LearningResourceViewEvent.objects.count() diff --git a/learning_resources/models.py b/learning_resources/models.py index bad9d0b830..049e002a3e 100644 --- a/learning_resources/models.py +++ b/learning_resources/models.py @@ -515,9 +515,7 @@ def next_run(self) -> Optional["LearningResourceRun"]: @cached_property def views_count(self) -> int: """Return the number of views for the resource.""" - return models.LearningResourceViewEvent.objects.filter( - learning_resource=self - ).count() + return LearningResourceViewEvent.objects.filter(learning_resource=self).count() @cached_property def user_list_parents(self) -> list["LearningResourceRelationship"]: diff --git a/learning_resources/tasks.py b/learning_resources/tasks.py index 16db1722b0..44192db255 100644 --- a/learning_resources/tasks.py +++ b/learning_resources/tasks.py @@ -421,6 +421,7 @@ def get_learning_resource_views(): """Load learning resource views from the PostHog ETL.""" pipelines.posthog_etl() + clear_search_cache() @app.task(acks_late=True) diff --git a/learning_resources_search/constants.py b/learning_resources_search/constants.py index c59c3c365e..9dc5af1fd2 100644 --- a/learning_resources_search/constants.py +++ b/learning_resources_search/constants.py @@ -122,6 +122,7 @@ class FilterConfig: "name": {"type": "keyword"}, }, }, + "views": {"type": "integer"}, "pace": { "type": "nested", "properties": { From d9dbc83fdf75b2467d880ca72b00ef9b382581c5 Mon Sep 17 00:00:00 2001 From: Anastasia Beglova Date: Mon, 18 Aug 2025 12:04:42 -0400 Subject: [PATCH 3/4] fix video etl (#2438) --- learning_resources/etl/loaders.py | 10 ++++++++++ learning_resources/etl/loaders_test.py | 22 ++++++++++++++++++---- 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/learning_resources/etl/loaders.py b/learning_resources/etl/loaders.py index 906ce5022c..8ab084e52e 100644 --- a/learning_resources/etl/loaders.py +++ b/learning_resources/etl/loaders.py @@ -1113,6 +1113,16 @@ def load_playlist(video_channel: VideoChannel, playlist_data: dict) -> LearningR video_resources = load_videos(videos_data) load_topics(playlist_resource, most_common_topics(video_resources)) + unpublished_videos = playlist_resource.resources.filter( + resource_type=LearningResourceType.video.name, + published=True, + ).exclude(id__in=[video.id for video in video_resources]) + unpublished_videos.update(published=False) + bulk_resources_unpublished_actions( + unpublished_videos.values_list("id", flat=True), + LearningResourceType.video.name, + ) + playlist_resource.resources.clear() for idx, video in enumerate(video_resources): playlist_resource.resources.add( diff --git a/learning_resources/etl/loaders_test.py b/learning_resources/etl/loaders_test.py index 3eb9e0518c..af4081a565 100644 --- a/learning_resources/etl/loaders_test.py +++ b/learning_resources/etl/loaders_test.py @@ -1463,7 +1463,8 @@ def test_load_videos(): assert Video.objects.count() == len(video_resources) -def test_load_playlist(mocker): +@pytest.mark.parametrize("playlist_exists", [True, False]) +def test_load_playlist(mocker, playlist_exists): """Test load_playlist""" expected_topics = [{"name": "Biology"}, {"name": "Physics"}] [ @@ -1475,9 +1476,19 @@ def test_load_playlist(mocker): return_value=expected_topics, ) channel = VideoChannelFactory.create() - playlist = VideoPlaylistFactory.build().learning_resource - assert VideoPlaylist.objects.count() == 0 - assert Video.objects.count() == 0 + if playlist_exists: + playlist = VideoPlaylistFactory.create(channel=channel).learning_resource + deleted_video = VideoFactory.create().learning_resource + playlist.resources.add( + deleted_video, + through_defaults={ + "relation_type": LearningResourceRelationTypes.PLAYLIST_VIDEOS, + "position": 1, + }, + ) + else: + playlist = VideoPlaylistFactory.build().learning_resource + video_resources = [video.learning_resource for video in VideoFactory.build_batch(5)] videos_data = [ { @@ -1511,6 +1522,9 @@ def test_load_playlist(mocker): assert list(result.topics.values_list("name", flat=True).order_by("name")) == [ topic["name"] for topic in expected_topics ] + if playlist_exists: + deleted_video.refresh_from_db() + assert not deleted_video.published def test_load_playlists_unpublish(mocker): From 42808982eb1c476a9413a50f61791173ada97964 Mon Sep 17 00:00:00 2001 From: Doof Date: Mon, 18 Aug 2025 16:18:35 +0000 Subject: [PATCH 4/4] Release 0.41.0 --- RELEASE.rst | 7 +++++++ main/settings.py | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/RELEASE.rst b/RELEASE.rst index 71c6df03df..217f54c4f0 100644 --- a/RELEASE.rst +++ b/RELEASE.rst @@ -1,6 +1,13 @@ Release Notes ============= +Version 0.41.0 +-------------- + +- fix video etl (#2438) +- fix popular search (#2436) +- B2B Provisioning: Add interstitial page to process redemption code retrieval (#2422) + Version 0.40.1 (Released August 18, 2025) -------------- diff --git a/main/settings.py b/main/settings.py index a09ee22073..dd7a973956 100644 --- a/main/settings.py +++ b/main/settings.py @@ -34,7 +34,7 @@ from main.settings_pluggy import * # noqa: F403 from openapi.settings_spectacular import open_spectacular_settings -VERSION = "0.40.1" +VERSION = "0.41.0" log = logging.getLogger()