diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 3026d93278..fb9b5cd8df 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -53,7 +53,7 @@ repos:
pass_filenames: false
always_run: true
- repo: https://github.com/scop/pre-commit-shfmt
- rev: v3.12.0-2
+ rev: v3.13.1-1
hooks:
- id: shfmt
- repo: https://github.com/adrienverge/yamllint.git
@@ -90,7 +90,7 @@ repos:
- "config/keycloak/realms/ol-local-realm.json"
additional_dependencies: ["gibberish-detector"]
- repo: https://github.com/astral-sh/ruff-pre-commit
- rev: "v0.15.1"
+ rev: "v0.15.12"
hooks:
- id: ruff-format
- id: ruff
diff --git a/RELEASE.rst b/RELEASE.rst
index e7c6025532..316c73ddef 100644
--- a/RELEASE.rst
+++ b/RELEASE.rst
@@ -1,6 +1,15 @@
Release Notes
=============
+Version 0.65.1
+--------------
+
+- do not promote ocw page contentfiles to resources (#3261)
+- dashboard b2c series certificate display (#3256)
+- flaky test test_learning_resources_serializer (#3252)
+- [pre-commit.ci] pre-commit autoupdate (#2973)
+- Update dependency sharp to v0.34.5 (#2707)
+
Version 0.65.0 (Released April 28, 2026)
--------------
diff --git a/frontends/main/package.json b/frontends/main/package.json
index acccf5759c..fe1573debd 100644
--- a/frontends/main/package.json
+++ b/frontends/main/package.json
@@ -63,7 +63,7 @@
"react-hotkeys-hook": "^5.2.1",
"react-markdown": "^10.0.0",
"react-slick": "^0.31.0",
- "sharp": "0.34.4",
+ "sharp": "0.34.5",
"slick-carousel": "^1.8.1",
"tiny-invariant": "^1.3.3",
"video.js": "^8.23.7",
diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/EnrollmentDisplay.test.tsx b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/EnrollmentDisplay.test.tsx
index 1ff5215871..324f84033e 100644
--- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/EnrollmentDisplay.test.tsx
+++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/EnrollmentDisplay.test.tsx
@@ -2291,5 +2291,103 @@ describe("EnrollmentDisplay", () => {
expect(cards[1]).toHaveTextContent(courseA.title)
expect(cards[2]).toHaveTextContent(courseB.title)
})
+
+ test("displays certificate button when program enrollment has a certificate", async () => {
+ const mitxOnlineUser = mitxonline.factories.user.user()
+ setMockResponse.get(mitxonline.urls.userMe.get(), mitxOnlineUser)
+
+ const certUuid = "test-program-cert-uuid"
+ const program = mitxonline.factories.programs.program({
+ id: 456,
+ title: "Program With Certificate",
+ courses: [10, 11],
+ })
+ const programEnrollment =
+ mitxonline.factories.enrollment.programEnrollmentV3({
+ program: {
+ id: program.id,
+ title: program.title,
+ live: program.live,
+ program_type: program.program_type,
+ readable_id: program.readable_id,
+ },
+ certificate: {
+ uuid: certUuid,
+ link: `/certificate/program/${certUuid}/`,
+ },
+ })
+ const courses = mitxonline.factories.courses.courses({ count: 2 })
+
+ mockedUseFeatureFlagEnabled.mockReturnValue(true)
+ setMockResponse.get(mitxonline.urls.enrollment.enrollmentsListV3(), [])
+ setMockResponse.get(
+ mitxonline.urls.programEnrollments.enrollmentsListV3(),
+ [programEnrollment],
+ )
+ setMockResponse.get(mitxonline.urls.programs.programDetail(456), program)
+ setMockResponse.get(
+ mitxonline.urls.courses.coursesList({
+ id: program.courses,
+ page_size: program.courses.length,
+ }),
+ courses,
+ )
+
+ renderWithProviders()
+
+ await screen.findByText("Program With Certificate")
+ const certButton = screen.getByRole("link", { name: "Certificate" })
+ const expectedCertHref = programEnrollment.certificate?.link?.replace(
+ /\/$/,
+ "",
+ )
+ expect(certButton).toBeInTheDocument()
+ expect(certButton).toHaveAttribute("href", expectedCertHref)
+ expect(certButton).not.toHaveAttribute("target")
+ })
+
+ test("does not display certificate button when program enrollment has no certificate", async () => {
+ const mitxOnlineUser = mitxonline.factories.user.user()
+ setMockResponse.get(mitxonline.urls.userMe.get(), mitxOnlineUser)
+
+ const program = mitxonline.factories.programs.program({
+ id: 457,
+ title: "Program Without Certificate",
+ courses: [12, 13],
+ })
+ const programEnrollment =
+ mitxonline.factories.enrollment.programEnrollmentV3({
+ program: {
+ id: program.id,
+ title: program.title,
+ live: program.live,
+ program_type: program.program_type,
+ readable_id: program.readable_id,
+ },
+ certificate: null,
+ })
+ const courses = mitxonline.factories.courses.courses({ count: 2 })
+
+ mockedUseFeatureFlagEnabled.mockReturnValue(true)
+ setMockResponse.get(mitxonline.urls.enrollment.enrollmentsListV3(), [])
+ setMockResponse.get(
+ mitxonline.urls.programEnrollments.enrollmentsListV3(),
+ [programEnrollment],
+ )
+ setMockResponse.get(mitxonline.urls.programs.programDetail(457), program)
+ setMockResponse.get(
+ mitxonline.urls.courses.coursesList({
+ id: program.courses,
+ page_size: program.courses.length,
+ }),
+ courses,
+ )
+
+ renderWithProviders()
+
+ await screen.findByText("Program Without Certificate")
+ const certButton = screen.queryByRole("link", { name: "Certificate" })
+ expect(certButton).not.toBeInTheDocument()
+ })
})
})
diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/EnrollmentDisplay.tsx b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/EnrollmentDisplay.tsx
index e89d7a66ed..02a06c2a29 100644
--- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/EnrollmentDisplay.tsx
+++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/EnrollmentDisplay.tsx
@@ -13,7 +13,7 @@ import {
styled,
theme,
} from "ol-components"
-import { Alert } from "@mitodl/smoot-design"
+import { Alert, ButtonLink } from "@mitodl/smoot-design"
import { keepPreviousData, useQuery } from "@tanstack/react-query"
import {
EnrollmentStatus,
@@ -43,6 +43,7 @@ import { mitxUserQueries } from "api/mitxonline-hooks/user"
import NotFoundPage from "@/app-pages/ErrorPage/NotFoundPage"
import { ProgramAsCourseCard } from "./ProgramAsCourseCard"
import { getIdsFromReqTree } from "@/common/mitxonline"
+import { RiAwardFill } from "@remixicon/react"
const Wrapper = styled.div(({ theme }) => ({
marginTop: "32px",
@@ -107,6 +108,11 @@ const ShowAllContainer = styled.div(({ theme }) => ({
},
}))
+export const ProgramCertificateButton = styled(ButtonLink)(({ theme }) => ({
+ color: theme.custom.colors.red,
+ width: "120px",
+}))
+
const alphabeticalSort = (a: CourseRunEnrollmentV3, b: CourseRunEnrollmentV3) =>
a.run.course.title.localeCompare(b.run.course.title)
@@ -550,6 +556,8 @@ const ProgramEnrollmentDisplay: React.FC = ({
programEnrollmentsById,
)
+ const programCertificateUrl = programEnrollment?.certificate?.link ?? null
+
if (isLoading) {
return (
@@ -578,14 +586,26 @@ const ProgramEnrollmentDisplay: React.FC = ({
{program?.title}
-
- You have completed
-
- {" "}
- {completedCount} of {totalCount} courses{" "}
+
+
+ You have completed
+
+ {" "}
+ {completedCount} of {totalCount} courses{" "}
+
+ for this program.
- for this program.
-
+ {programCertificateUrl && (
+ }
+ href={programCertificateUrl}
+ >
+ Certificate
+
+ )}
+
{requirementSections.map((section, index) => {
const { completed: sectionCompleted, total: sectionTotal } =
diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ProgramAsCourseCard.test.tsx b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ProgramAsCourseCard.test.tsx
index 82bd3becc3..3a501d41cf 100644
--- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ProgramAsCourseCard.test.tsx
+++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ProgramAsCourseCard.test.tsx
@@ -13,6 +13,14 @@ import { ProgramAsCourseCard } from "./ProgramAsCourseCard"
import { waitFor } from "@testing-library/react"
import invariant from "tiny-invariant"
import moment from "moment"
+import NiceModal from "@ebay/nice-modal-react"
+import { useFeatureFlagEnabled } from "posthog-js/react"
+import { UnenrollProgramDialog } from "./DashboardDialogs"
+
+jest.mock("posthog-js/react")
+const mockedUseFeatureFlagEnabled = jest
+ .mocked(useFeatureFlagEnabled)
+ .mockImplementation(() => false)
describe("ProgramAsCourseCard", () => {
setupLocationMock()
@@ -364,4 +372,168 @@ describe("ProgramAsCourseCard", () => {
screen.queryByRole("dialog", { name: moduleWithRun.title }),
).not.toBeInTheDocument()
})
+
+ test("displays certificate button when program enrollment has a certificate", async () => {
+ const cardData = setupCardData({ includeProgramEnrollment: true })
+ invariant(cardData.courseProgramEnrollment)
+ const certUuid = "test-certificate-uuid-123"
+ const programEnrollmentWithCert = {
+ ...cardData.courseProgramEnrollment,
+ certificate: {
+ uuid: certUuid,
+ link: `/certificate/program/${certUuid}/`,
+ },
+ }
+
+ renderWithProviders(
+ ,
+ )
+
+ await screen.findByText(cardData.courseProgram.title)
+ const certButton = screen.getByRole("link", { name: "Certificate" })
+ const expectedCertHref = programEnrollmentWithCert.certificate.link.replace(
+ /\/$/,
+ "",
+ )
+ expect(certButton).toBeInTheDocument()
+ expect(certButton).toHaveAttribute("href", expectedCertHref)
+ expect(certButton).not.toHaveAttribute("target")
+ })
+
+ test("does not display certificate button when program enrollment has no certificate", async () => {
+ const cardData = setupCardData({ includeProgramEnrollment: true })
+ invariant(cardData.courseProgramEnrollment)
+ const programEnrollmentNoCert = {
+ ...cardData.courseProgramEnrollment,
+ certificate: null,
+ }
+
+ renderWithProviders(
+ ,
+ )
+
+ await screen.findByText(cardData.courseProgram.title)
+ const certButton = screen.queryByRole("link", { name: "Certificate" })
+ expect(certButton).not.toBeInTheDocument()
+ })
+
+ test("shows legacy details link in context menu when product pages flag is disabled", async () => {
+ mockedUseFeatureFlagEnabled.mockReturnValue(false)
+ const cardData = setupCardData({ includeProgramEnrollment: true })
+
+ renderWithProviders(
+ ,
+ )
+
+ await screen.findByText(cardData.courseProgram.title)
+ const programCard = screen.getByTestId("program-as-course-card")
+ await user.click(within(programCard).getAllByLabelText("More options")[0])
+
+ const detailsLink = await screen.findByRole("menuitem", {
+ name: "View Course Details",
+ })
+ expect(detailsLink).toHaveAttribute(
+ "href",
+ expect.stringContaining(
+ `/programs/${cardData.courseProgram.readable_id}`,
+ ),
+ )
+ expect(detailsLink).toHaveAttribute(
+ "href",
+ expect.stringContaining("ecom-service=true"),
+ )
+ })
+
+ test("shows product-page details link in context menu when product pages flag is enabled", async () => {
+ mockedUseFeatureFlagEnabled.mockReturnValue(true)
+ const cardData = setupCardData({ includeProgramEnrollment: true })
+
+ renderWithProviders(
+ ,
+ )
+
+ await screen.findByText(cardData.courseProgram.title)
+ const programCard = screen.getByTestId("program-as-course-card")
+ await user.click(within(programCard).getAllByLabelText("More options")[0])
+
+ const detailsLink = await screen.findByRole("menuitem", {
+ name: "View Course Details",
+ })
+ expect(detailsLink).toHaveAttribute(
+ "href",
+ `/courses/p/${cardData.courseProgram.readable_id}`,
+ )
+ })
+
+ test("clicking Unenroll menu item opens UnenrollProgramDialog with readable_id", async () => {
+ mockedUseFeatureFlagEnabled.mockReturnValue(false)
+ const cardData = setupCardData({ includeProgramEnrollment: true })
+ invariant(cardData.courseProgramEnrollment)
+ cardData.courseProgramEnrollment.enrollment_mode = "audit"
+ const modalShowSpy = jest.spyOn(NiceModal, "show")
+
+ renderWithProviders(
+ ,
+ )
+
+ await screen.findByText(cardData.courseProgram.title)
+ const programCard = screen.getByTestId("program-as-course-card")
+ await user.click(within(programCard).getAllByLabelText("More options")[0])
+ await user.click(await screen.findByRole("menuitem", { name: "Unenroll" }))
+
+ expect(modalShowSpy).toHaveBeenCalledWith(UnenrollProgramDialog, {
+ title: cardData.courseProgram.title,
+ enrollment: cardData.courseProgram.readable_id,
+ })
+ modalShowSpy.mockRestore()
+ })
+
+ test("does not show Unenroll option in context menu for verified enrollment", async () => {
+ mockedUseFeatureFlagEnabled.mockReturnValue(false)
+ const cardData = setupCardData({ includeProgramEnrollment: true })
+ invariant(cardData.courseProgramEnrollment)
+ cardData.courseProgramEnrollment.enrollment_mode = "verified"
+
+ renderWithProviders(
+ ,
+ )
+
+ await screen.findByText(cardData.courseProgram.title)
+ const programCard = screen.getByTestId("program-as-course-card")
+ await user.click(within(programCard).getAllByLabelText("More options")[0])
+
+ expect(
+ screen.queryByRole("menuitem", { name: "Unenroll" }),
+ ).not.toBeInTheDocument()
+ })
})
diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ProgramAsCourseCard.tsx b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ProgramAsCourseCard.tsx
index f3a4cf8d2c..752f47cba3 100644
--- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ProgramAsCourseCard.tsx
+++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ProgramAsCourseCard.tsx
@@ -1,5 +1,13 @@
import React from "react"
-import { Link, Popover, Stack, Typography, styled } from "ol-components"
+import {
+ Link,
+ Popover,
+ SimpleMenu,
+ SimpleMenuItem,
+ Stack,
+ Typography,
+ styled,
+} from "ol-components"
import {
CourseRunEnrollmentV3,
CourseWithCourseRunsSerializerV2,
@@ -23,7 +31,16 @@ import { formatDate } from "ol-utilities"
import {
getIdsFromReqTree,
isVerifiedEnrollmentMode,
+ mitxonlineLegacyUrl,
} from "@/common/mitxonline"
+import { ActionButton } from "@mitodl/smoot-design"
+import { RiAwardFill, RiMore2Line } from "@remixicon/react"
+import NiceModal from "@ebay/nice-modal-react"
+import { UnenrollProgramDialog } from "./DashboardDialogs"
+import { ProgramCertificateButton } from "./EnrollmentDisplay"
+import { useFeatureFlagEnabled } from "posthog-js/react"
+import { FeatureFlags } from "@/common/feature_flags"
+import { programPageView } from "@/common/urls"
const ProgramCardRoot = styled.div(({ theme }) => ({
display: "flex",
@@ -127,6 +144,23 @@ const ProgramCardBody = styled.div({
borderRadius: "0 0 8px 8px",
})
+const MenuButton = styled(ActionButton)<{
+ status: EnrollmentStatus
+}>(({ theme, status }) => [
+ {
+ marginLeft: "-8px",
+ [theme.breakpoints.down("md")]: {
+ position: "absolute",
+ top: "0",
+ right: "0",
+ },
+ },
+ status !== EnrollmentStatus.Completed &&
+ status !== EnrollmentStatus.Enrolled && {
+ visibility: "hidden",
+ },
+])
+
const getTimezone = (dateString: string): string => {
const tz =
new Date(dateString)
@@ -246,19 +280,65 @@ const getRelativeDateContent = (
}
}
+const getContextMenuItems = (
+ title: string,
+ resource: ProgramAsCourse,
+ enrollmentMode: string | null | undefined,
+ additionalItems: SimpleMenuItem[] = [],
+ useProductPages = false,
+) => {
+ const menuItems = []
+ const detailsUrl = useProductPages
+ ? programPageView({
+ readable_id: resource.readable_id,
+ display_mode: "course",
+ })
+ : mitxonlineLegacyUrl(`/programs/${resource.readable_id}`)
+
+ const courseMenuItems = []
+
+ if (detailsUrl) {
+ courseMenuItems.push({
+ className: "dashboard-card-menu-item",
+ key: "view-course-details",
+ label: "View Course Details",
+ href: detailsUrl,
+ })
+ }
+
+ if (enrollmentMode && !isVerifiedEnrollmentMode(enrollmentMode)) {
+ courseMenuItems.push({
+ className: "dashboard-card-menu-item",
+ key: "unenroll",
+ label: "Unenroll",
+ onClick: () => {
+ NiceModal.show(UnenrollProgramDialog, {
+ title,
+ enrollment: resource.readable_id,
+ })
+ },
+ })
+ }
+
+ menuItems.push(...courseMenuItems)
+ return [...menuItems, ...additionalItems]
+}
+
+interface ProgramAsCourse {
+ id: number
+ readable_id: string
+ title?: string | null
+ start_date?: string | null
+ end_date?: string | null
+ courses?: number[]
+ req_tree?: V2ProgramRequirement[]
+}
+
interface ProgramAsCourseCardProps {
/**
* The courselike program to display.
*/
- courseProgram: {
- id: number
- readable_id: string
- title?: string | null
- start_date?: string | null
- end_date?: string | null
- courses?: number[]
- req_tree?: V2ProgramRequirement[]
- }
+ courseProgram: ProgramAsCourse
/**
* child courses of the program. These correspond to nodes in the req_tree.
*/
@@ -289,6 +369,7 @@ interface ProgramAsCourseCardProps {
enrollment_mode?: string | null
}
Component?: React.ElementType
+ contextMenuItems?: SimpleMenuItem[]
className?: string
}
@@ -313,8 +394,12 @@ const ProgramAsCourseCard: React.FC = ({
courseProgramEnrollment,
ancestorProgramEnrollment,
Component,
+ contextMenuItems = [],
className,
}) => {
+ const useProductPages = useFeatureFlagEnabled(
+ FeatureFlags.MitxOnlineProductPages,
+ )
const moduleRequirementSection = courseProgram?.req_tree?.find(
(node) => node.data.node_type === "operator",
)
@@ -384,6 +469,35 @@ const ProgramAsCourseCard: React.FC = ({
ancestorProgramEnrollment?.enrollment_mode,
].some(isVerifiedEnrollmentMode)
+ const programCertificateUrl =
+ courseProgramEnrollment?.certificate?.link ?? null
+
+ // Build context menu
+ const menuItems = getContextMenuItems(
+ courseProgram.title ?? "",
+ courseProgram,
+ courseProgramEnrollment?.enrollment_mode,
+ contextMenuItems,
+ useProductPages ?? false,
+ )
+
+ const contextMenu = (
+
+
+
+ }
+ />
+ )
+
return (
= ({
{courseProgram?.title}
+ <>
+ {programCertificateUrl && (
+ }
+ href={programCertificateUrl}
+ >
+ Certificate
+
+ )}
+ {contextMenu}
+ >
diff --git a/learning_resources/etl/loaders.py b/learning_resources/etl/loaders.py
index d1c6fe01a5..1fe7a9d4b1 100644
--- a/learning_resources/etl/loaders.py
+++ b/learning_resources/etl/loaders.py
@@ -10,6 +10,7 @@
from django.db.models import Q
from learning_resources.constants import (
+ CONTENT_TYPE_PAGE,
OCW_COURSE_CONTENT_CATEGORY_MAPPING,
VIDEO_CONTENT_CATEGORIES,
LearningResourceDelivery,
@@ -1034,7 +1035,7 @@ def load_learning_materials(
learning_material_tags = set(
content_file.content_tags.values_list("name", flat=True)
) & set(OCW_COURSE_CONTENT_CATEGORY_MAPPING.keys())
- if learning_material_tags:
+ if content_file.content_type != CONTENT_TYPE_PAGE and learning_material_tags:
material_ids.append(
load_learning_material(course_run, content_file, learning_material_tags)
)
diff --git a/learning_resources/etl/loaders_test.py b/learning_resources/etl/loaders_test.py
index 8649bd7c25..ebbc30d4f9 100644
--- a/learning_resources/etl/loaders_test.py
+++ b/learning_resources/etl/loaders_test.py
@@ -12,6 +12,8 @@
from django.forms.models import model_to_dict
from learning_resources.constants import (
+ CONTENT_TYPE_FILE,
+ CONTENT_TYPE_PAGE,
CURRENCY_USD,
Availability,
LearningResourceDelivery,
@@ -3005,12 +3007,14 @@ def test_load_learning_materials(mocker):
learning_material_content_file = ContentFileFactory.create(
run=ocw_course.learning_resource.runs.first(),
content_tags=[relevant_content_tag],
+ content_type=CONTENT_TYPE_FILE,
)
other_content_file = ContentFileFactory.create(
run=ocw_course.learning_resource.runs.first(),
content_tags=[irrelevant_content_tag],
direct_learning_resource=no_longer_relevant_resource,
+ content_type=CONTENT_TYPE_FILE,
)
mock_index = mocker.patch("learning_resources.etl.loaders.update_index")
@@ -3052,6 +3056,51 @@ def test_load_learning_materials(mocker):
assert no_longer_relevant_resource.published is False
+@pytest.mark.django_db
+def test_load_learning_materials_demotes_page_content_files(mocker):
+ """
+ Page content files should not be promoted to learning resources.
+ If a page content file was previously promoted (has direct_learning_resource),
+ load_learning_materials should unpublish that resource and clear the link.
+ """
+ ocw = LearningResourcePlatformFactory.create(code=PlatformType.ocw.name)
+ ocw_course = CourseFactory.create(
+ platform=ocw.code,
+ learning_resource__is_course=True,
+ )
+ relevant_content_tag = LearningResourceContentTagFactory.create(
+ name="Programming Assignments"
+ )
+ previously_promoted_resource = LearningResourceFactory.create(published=True)
+
+ page_content_file = ContentFileFactory.create(
+ run=ocw_course.learning_resource.runs.first(),
+ content_tags=[relevant_content_tag],
+ direct_learning_resource=previously_promoted_resource,
+ content_type=CONTENT_TYPE_PAGE,
+ )
+
+ mock_index = mocker.patch("learning_resources.etl.loaders.update_index")
+
+ loaders.load_learning_materials(
+ course_run=ocw_course.learning_resource.runs.first(),
+ content_file_ids=[page_content_file.id],
+ )
+
+ previously_promoted_resource.refresh_from_db()
+ assert previously_promoted_resource.published is False
+
+ page_content_file.refresh_from_db()
+ assert page_content_file.direct_learning_resource is None
+
+ mock_index.assert_called_once_with(
+ previously_promoted_resource, newly_created=False
+ )
+
+ # No learning materials should be linked to the course
+ assert ocw_course.learning_resource.children.count() == 0
+
+
@pytest.mark.django_db
@pytest.mark.parametrize("learning_material_exists", [True, False])
def test_load_learning_material(mocker, learning_material_exists):
diff --git a/learning_resources/etl/openedx.py b/learning_resources/etl/openedx.py
index 2f5f574dce..cebd2c2bc4 100644
--- a/learning_resources/etl/openedx.py
+++ b/learning_resources/etl/openedx.py
@@ -378,9 +378,10 @@ def _transform_course_commitment(course_run) -> CommitmentConfig:
)
if min_effort or max_effort:
return CommitmentConfig(
- commitment=f"{commit_str_prefix}{max_effort or min_effort} hour{
- 's' if max_effort > 1 else ''
- }/week",
+ commitment=(
+ f"{commit_str_prefix}{max_effort or min_effort} "
+ f"hour{'s' if max_effort > 1 else ''}/week"
+ ),
min_weekly_hours=min(min_effort, max_effort),
max_weekly_hours=max(min_effort, max_effort),
)
diff --git a/learning_resources/migrations/0113_alter_learningresourcerelationship_options.py b/learning_resources/migrations/0113_alter_learningresourcerelationship_options.py
new file mode 100644
index 0000000000..39184191d3
--- /dev/null
+++ b/learning_resources/migrations/0113_alter_learningresourcerelationship_options.py
@@ -0,0 +1,16 @@
+# Generated by Django 4.2.30 on 2026-04-27 16:16
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("learning_resources", "0112_contentfile_youtube_id"),
+ ]
+
+ operations = [
+ migrations.AlterModelOptions(
+ name="learningresourcerelationship",
+ options={"ordering": ["position", "id"]},
+ ),
+ ]
diff --git a/learning_resources/models.py b/learning_resources/models.py
index c830f41701..58993ffee5 100644
--- a/learning_resources/models.py
+++ b/learning_resources/models.py
@@ -664,7 +664,9 @@ def children_for_serialization(self):
if hasattr(self, "_children"):
return self._children
return list(
- self.children.order_by("position").select_related("child", "child__image")
+ self.children.order_by("position", "id").select_related(
+ "child", "child__image"
+ )
)
def first_child_relationship_for_serialization(self):
@@ -1012,7 +1014,7 @@ class LearningResourceRelationshipQuerySet(TimestampedModelQuerySet):
def for_serialization(self):
"""Prefetch related objects used by API serializers"""
- return self.select_related("child", "child__image").order_by("position")
+ return self.select_related("child", "child__image").order_by("position", "id")
class LearningResourceRelationship(TimestampedModel):
@@ -1038,7 +1040,7 @@ class LearningResourceRelationship(TimestampedModel):
objects = LearningResourceRelationshipQuerySet.as_manager()
class Meta:
- ordering = ["position"]
+ ordering = ["position", "id"]
class ContentFileQuerySet(TimestampedModelQuerySet):
diff --git a/main/settings.py b/main/settings.py
index 2d36ae984d..9c4c9d9a40 100644
--- a/main/settings.py
+++ b/main/settings.py
@@ -35,7 +35,7 @@
from main.settings_pluggy import * # noqa: F403
from openapi.settings_spectacular import open_spectacular_settings
-VERSION = "0.65.0"
+VERSION = "0.65.1"
log = logging.getLogger()
diff --git a/pyproject.toml b/pyproject.toml
index 10b1bab7fb..2bafe84139 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -136,6 +136,7 @@ dev = [
"pytest-env>=1.0.0,<2",
"pytest-freezegun>=0.4.2,<0.5",
"pytest-mock>=3.10.0,<4",
+ "pytest-repeat>=0.9.4",
"responses>=0.25.0,<0.26",
"ruff==0.14.14",
"safety>=3.0.0,<4",
diff --git a/uv.lock b/uv.lock
index dc5222734e..001d7183cf 100644
--- a/uv.lock
+++ b/uv.lock
@@ -2616,6 +2616,7 @@ dev = [
{ name = "pytest-env" },
{ name = "pytest-freezegun" },
{ name = "pytest-mock" },
+ { name = "pytest-repeat" },
{ name = "pytest-xdist", extra = ["psutil"] },
{ name = "responses" },
{ name = "ruff" },
@@ -2755,6 +2756,7 @@ dev = [
{ name = "pytest-env", specifier = ">=1.0.0,<2" },
{ name = "pytest-freezegun", specifier = ">=0.4.2,<0.5" },
{ name = "pytest-mock", specifier = ">=3.10.0,<4" },
+ { name = "pytest-repeat", specifier = ">=0.9.4" },
{ name = "pytest-xdist", extras = ["psutil"], specifier = ">=3.6.1,<4" },
{ name = "responses", specifier = ">=0.25.0,<0.26" },
{ name = "ruff", specifier = "==0.14.14" },
@@ -4070,6 +4072,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d", size = 10095, upload-time = "2025-09-16T16:37:25.734Z" },
]
+[[package]]
+name = "pytest-repeat"
+version = "0.9.4"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pytest" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/80/d4/69e9dbb9b8266df0b157c72be32083403c412990af15c7c15f7a3fd1b142/pytest_repeat-0.9.4.tar.gz", hash = "sha256:d92ac14dfaa6ffcfe6917e5d16f0c9bc82380c135b03c2a5f412d2637f224485", size = 6488, upload-time = "2025-04-07T14:59:53.077Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/73/d4/8b706b81b07b43081bd68a2c0359fe895b74bf664b20aca8005d2bb3be71/pytest_repeat-0.9.4-py3-none-any.whl", hash = "sha256:c1738b4e412a6f3b3b9e0b8b29fcd7a423e50f87381ad9307ef6f5a8601139f3", size = 4180, upload-time = "2025-04-07T14:59:51.492Z" },
+]
+
[[package]]
name = "pytest-xdist"
version = "3.8.0"
diff --git a/yarn.lock b/yarn.lock
index 7db1909227..0ccbbdd1d4 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -2035,15 +2035,6 @@ __metadata:
languageName: node
linkType: hard
-"@emnapi/runtime@npm:^1.5.0":
- version: 1.7.0
- resolution: "@emnapi/runtime@npm:1.7.0"
- dependencies:
- tslib: "npm:^2.4.0"
- checksum: 10/4dc726eb42fe2c7777fd32090f3e5e006c630e1a732538139caa18daf586e883e81c562cd69b0622db16e76bb572a2dde30711494edcee4a34059b62f5f46267
- languageName: node
- linkType: hard
-
"@emnapi/wasi-threads@npm:1.0.2":
version: 1.0.2
resolution: "@emnapi/wasi-threads@npm:1.0.2"
@@ -2620,18 +2611,6 @@ __metadata:
languageName: node
linkType: hard
-"@img/sharp-darwin-arm64@npm:0.34.4":
- version: 0.34.4
- resolution: "@img/sharp-darwin-arm64@npm:0.34.4"
- dependencies:
- "@img/sharp-libvips-darwin-arm64": "npm:1.2.3"
- dependenciesMeta:
- "@img/sharp-libvips-darwin-arm64":
- optional: true
- conditions: os=darwin & cpu=arm64
- languageName: node
- linkType: hard
-
"@img/sharp-darwin-arm64@npm:0.34.5":
version: 0.34.5
resolution: "@img/sharp-darwin-arm64@npm:0.34.5"
@@ -2644,18 +2623,6 @@ __metadata:
languageName: node
linkType: hard
-"@img/sharp-darwin-x64@npm:0.34.4":
- version: 0.34.4
- resolution: "@img/sharp-darwin-x64@npm:0.34.4"
- dependencies:
- "@img/sharp-libvips-darwin-x64": "npm:1.2.3"
- dependenciesMeta:
- "@img/sharp-libvips-darwin-x64":
- optional: true
- conditions: os=darwin & cpu=x64
- languageName: node
- linkType: hard
-
"@img/sharp-darwin-x64@npm:0.34.5":
version: 0.34.5
resolution: "@img/sharp-darwin-x64@npm:0.34.5"
@@ -2668,13 +2635,6 @@ __metadata:
languageName: node
linkType: hard
-"@img/sharp-libvips-darwin-arm64@npm:1.2.3":
- version: 1.2.3
- resolution: "@img/sharp-libvips-darwin-arm64@npm:1.2.3"
- conditions: os=darwin & cpu=arm64
- languageName: node
- linkType: hard
-
"@img/sharp-libvips-darwin-arm64@npm:1.2.4":
version: 1.2.4
resolution: "@img/sharp-libvips-darwin-arm64@npm:1.2.4"
@@ -2682,13 +2642,6 @@ __metadata:
languageName: node
linkType: hard
-"@img/sharp-libvips-darwin-x64@npm:1.2.3":
- version: 1.2.3
- resolution: "@img/sharp-libvips-darwin-x64@npm:1.2.3"
- conditions: os=darwin & cpu=x64
- languageName: node
- linkType: hard
-
"@img/sharp-libvips-darwin-x64@npm:1.2.4":
version: 1.2.4
resolution: "@img/sharp-libvips-darwin-x64@npm:1.2.4"
@@ -2696,13 +2649,6 @@ __metadata:
languageName: node
linkType: hard
-"@img/sharp-libvips-linux-arm64@npm:1.2.3":
- version: 1.2.3
- resolution: "@img/sharp-libvips-linux-arm64@npm:1.2.3"
- conditions: os=linux & cpu=arm64 & libc=glibc
- languageName: node
- linkType: hard
-
"@img/sharp-libvips-linux-arm64@npm:1.2.4":
version: 1.2.4
resolution: "@img/sharp-libvips-linux-arm64@npm:1.2.4"
@@ -2710,13 +2656,6 @@ __metadata:
languageName: node
linkType: hard
-"@img/sharp-libvips-linux-arm@npm:1.2.3":
- version: 1.2.3
- resolution: "@img/sharp-libvips-linux-arm@npm:1.2.3"
- conditions: os=linux & cpu=arm & libc=glibc
- languageName: node
- linkType: hard
-
"@img/sharp-libvips-linux-arm@npm:1.2.4":
version: 1.2.4
resolution: "@img/sharp-libvips-linux-arm@npm:1.2.4"
@@ -2724,13 +2663,6 @@ __metadata:
languageName: node
linkType: hard
-"@img/sharp-libvips-linux-ppc64@npm:1.2.3":
- version: 1.2.3
- resolution: "@img/sharp-libvips-linux-ppc64@npm:1.2.3"
- conditions: os=linux & cpu=ppc64 & libc=glibc
- languageName: node
- linkType: hard
-
"@img/sharp-libvips-linux-ppc64@npm:1.2.4":
version: 1.2.4
resolution: "@img/sharp-libvips-linux-ppc64@npm:1.2.4"
@@ -2745,13 +2677,6 @@ __metadata:
languageName: node
linkType: hard
-"@img/sharp-libvips-linux-s390x@npm:1.2.3":
- version: 1.2.3
- resolution: "@img/sharp-libvips-linux-s390x@npm:1.2.3"
- conditions: os=linux & cpu=s390x & libc=glibc
- languageName: node
- linkType: hard
-
"@img/sharp-libvips-linux-s390x@npm:1.2.4":
version: 1.2.4
resolution: "@img/sharp-libvips-linux-s390x@npm:1.2.4"
@@ -2759,13 +2684,6 @@ __metadata:
languageName: node
linkType: hard
-"@img/sharp-libvips-linux-x64@npm:1.2.3":
- version: 1.2.3
- resolution: "@img/sharp-libvips-linux-x64@npm:1.2.3"
- conditions: os=linux & cpu=x64 & libc=glibc
- languageName: node
- linkType: hard
-
"@img/sharp-libvips-linux-x64@npm:1.2.4":
version: 1.2.4
resolution: "@img/sharp-libvips-linux-x64@npm:1.2.4"
@@ -2773,13 +2691,6 @@ __metadata:
languageName: node
linkType: hard
-"@img/sharp-libvips-linuxmusl-arm64@npm:1.2.3":
- version: 1.2.3
- resolution: "@img/sharp-libvips-linuxmusl-arm64@npm:1.2.3"
- conditions: os=linux & cpu=arm64 & libc=musl
- languageName: node
- linkType: hard
-
"@img/sharp-libvips-linuxmusl-arm64@npm:1.2.4":
version: 1.2.4
resolution: "@img/sharp-libvips-linuxmusl-arm64@npm:1.2.4"
@@ -2787,13 +2698,6 @@ __metadata:
languageName: node
linkType: hard
-"@img/sharp-libvips-linuxmusl-x64@npm:1.2.3":
- version: 1.2.3
- resolution: "@img/sharp-libvips-linuxmusl-x64@npm:1.2.3"
- conditions: os=linux & cpu=x64 & libc=musl
- languageName: node
- linkType: hard
-
"@img/sharp-libvips-linuxmusl-x64@npm:1.2.4":
version: 1.2.4
resolution: "@img/sharp-libvips-linuxmusl-x64@npm:1.2.4"
@@ -2801,18 +2705,6 @@ __metadata:
languageName: node
linkType: hard
-"@img/sharp-linux-arm64@npm:0.34.4":
- version: 0.34.4
- resolution: "@img/sharp-linux-arm64@npm:0.34.4"
- dependencies:
- "@img/sharp-libvips-linux-arm64": "npm:1.2.3"
- dependenciesMeta:
- "@img/sharp-libvips-linux-arm64":
- optional: true
- conditions: os=linux & cpu=arm64 & libc=glibc
- languageName: node
- linkType: hard
-
"@img/sharp-linux-arm64@npm:0.34.5":
version: 0.34.5
resolution: "@img/sharp-linux-arm64@npm:0.34.5"
@@ -2825,18 +2717,6 @@ __metadata:
languageName: node
linkType: hard
-"@img/sharp-linux-arm@npm:0.34.4":
- version: 0.34.4
- resolution: "@img/sharp-linux-arm@npm:0.34.4"
- dependencies:
- "@img/sharp-libvips-linux-arm": "npm:1.2.3"
- dependenciesMeta:
- "@img/sharp-libvips-linux-arm":
- optional: true
- conditions: os=linux & cpu=arm & libc=glibc
- languageName: node
- linkType: hard
-
"@img/sharp-linux-arm@npm:0.34.5":
version: 0.34.5
resolution: "@img/sharp-linux-arm@npm:0.34.5"
@@ -2849,18 +2729,6 @@ __metadata:
languageName: node
linkType: hard
-"@img/sharp-linux-ppc64@npm:0.34.4":
- version: 0.34.4
- resolution: "@img/sharp-linux-ppc64@npm:0.34.4"
- dependencies:
- "@img/sharp-libvips-linux-ppc64": "npm:1.2.3"
- dependenciesMeta:
- "@img/sharp-libvips-linux-ppc64":
- optional: true
- conditions: os=linux & cpu=ppc64 & libc=glibc
- languageName: node
- linkType: hard
-
"@img/sharp-linux-ppc64@npm:0.34.5":
version: 0.34.5
resolution: "@img/sharp-linux-ppc64@npm:0.34.5"
@@ -2885,18 +2753,6 @@ __metadata:
languageName: node
linkType: hard
-"@img/sharp-linux-s390x@npm:0.34.4":
- version: 0.34.4
- resolution: "@img/sharp-linux-s390x@npm:0.34.4"
- dependencies:
- "@img/sharp-libvips-linux-s390x": "npm:1.2.3"
- dependenciesMeta:
- "@img/sharp-libvips-linux-s390x":
- optional: true
- conditions: os=linux & cpu=s390x & libc=glibc
- languageName: node
- linkType: hard
-
"@img/sharp-linux-s390x@npm:0.34.5":
version: 0.34.5
resolution: "@img/sharp-linux-s390x@npm:0.34.5"
@@ -2909,18 +2765,6 @@ __metadata:
languageName: node
linkType: hard
-"@img/sharp-linux-x64@npm:0.34.4":
- version: 0.34.4
- resolution: "@img/sharp-linux-x64@npm:0.34.4"
- dependencies:
- "@img/sharp-libvips-linux-x64": "npm:1.2.3"
- dependenciesMeta:
- "@img/sharp-libvips-linux-x64":
- optional: true
- conditions: os=linux & cpu=x64 & libc=glibc
- languageName: node
- linkType: hard
-
"@img/sharp-linux-x64@npm:0.34.5":
version: 0.34.5
resolution: "@img/sharp-linux-x64@npm:0.34.5"
@@ -2933,18 +2777,6 @@ __metadata:
languageName: node
linkType: hard
-"@img/sharp-linuxmusl-arm64@npm:0.34.4":
- version: 0.34.4
- resolution: "@img/sharp-linuxmusl-arm64@npm:0.34.4"
- dependencies:
- "@img/sharp-libvips-linuxmusl-arm64": "npm:1.2.3"
- dependenciesMeta:
- "@img/sharp-libvips-linuxmusl-arm64":
- optional: true
- conditions: os=linux & cpu=arm64 & libc=musl
- languageName: node
- linkType: hard
-
"@img/sharp-linuxmusl-arm64@npm:0.34.5":
version: 0.34.5
resolution: "@img/sharp-linuxmusl-arm64@npm:0.34.5"
@@ -2957,18 +2789,6 @@ __metadata:
languageName: node
linkType: hard
-"@img/sharp-linuxmusl-x64@npm:0.34.4":
- version: 0.34.4
- resolution: "@img/sharp-linuxmusl-x64@npm:0.34.4"
- dependencies:
- "@img/sharp-libvips-linuxmusl-x64": "npm:1.2.3"
- dependenciesMeta:
- "@img/sharp-libvips-linuxmusl-x64":
- optional: true
- conditions: os=linux & cpu=x64 & libc=musl
- languageName: node
- linkType: hard
-
"@img/sharp-linuxmusl-x64@npm:0.34.5":
version: 0.34.5
resolution: "@img/sharp-linuxmusl-x64@npm:0.34.5"
@@ -2981,15 +2801,6 @@ __metadata:
languageName: node
linkType: hard
-"@img/sharp-wasm32@npm:0.34.4":
- version: 0.34.4
- resolution: "@img/sharp-wasm32@npm:0.34.4"
- dependencies:
- "@emnapi/runtime": "npm:^1.5.0"
- conditions: cpu=wasm32
- languageName: node
- linkType: hard
-
"@img/sharp-wasm32@npm:0.34.5":
version: 0.34.5
resolution: "@img/sharp-wasm32@npm:0.34.5"
@@ -2999,13 +2810,6 @@ __metadata:
languageName: node
linkType: hard
-"@img/sharp-win32-arm64@npm:0.34.4":
- version: 0.34.4
- resolution: "@img/sharp-win32-arm64@npm:0.34.4"
- conditions: os=win32 & cpu=arm64
- languageName: node
- linkType: hard
-
"@img/sharp-win32-arm64@npm:0.34.5":
version: 0.34.5
resolution: "@img/sharp-win32-arm64@npm:0.34.5"
@@ -3013,13 +2817,6 @@ __metadata:
languageName: node
linkType: hard
-"@img/sharp-win32-ia32@npm:0.34.4":
- version: 0.34.4
- resolution: "@img/sharp-win32-ia32@npm:0.34.4"
- conditions: os=win32 & cpu=ia32
- languageName: node
- linkType: hard
-
"@img/sharp-win32-ia32@npm:0.34.5":
version: 0.34.5
resolution: "@img/sharp-win32-ia32@npm:0.34.5"
@@ -3027,13 +2824,6 @@ __metadata:
languageName: node
linkType: hard
-"@img/sharp-win32-x64@npm:0.34.4":
- version: 0.34.4
- resolution: "@img/sharp-win32-x64@npm:0.34.4"
- conditions: os=win32 & cpu=x64
- languageName: node
- linkType: hard
-
"@img/sharp-win32-x64@npm:0.34.5":
version: 0.34.5
resolution: "@img/sharp-win32-x64@npm:0.34.5"
@@ -11225,7 +11015,7 @@ __metadata:
languageName: node
linkType: hard
-"detect-libc@npm:^2.1.0, detect-libc@npm:^2.1.2":
+"detect-libc@npm:^2.1.2":
version: 2.1.2
resolution: "detect-libc@npm:2.1.2"
checksum: 10/b736c8d97d5d46164c0d1bed53eb4e6a3b1d8530d460211e2d52f1c552875e706c58a5376854e4e54f8b828c9cada58c855288c968522eb93ac7696d65970766
@@ -16461,7 +16251,7 @@ __metadata:
react-hotkeys-hook: "npm:^5.2.1"
react-markdown: "npm:^10.0.0"
react-slick: "npm:^0.31.0"
- sharp: "npm:0.34.4"
+ sharp: "npm:0.34.5"
slick-carousel: "npm:^1.8.1"
tiny-invariant: "npm:^1.3.3"
ts-jest: "npm:^29.2.4"
@@ -20633,7 +20423,7 @@ __metadata:
languageName: node
linkType: hard
-"semver@npm:^7.1.1, semver@npm:^7.3.5, semver@npm:^7.5.3, semver@npm:^7.5.4, semver@npm:^7.6.0, semver@npm:^7.6.3, semver@npm:^7.7.1, semver@npm:^7.7.2":
+"semver@npm:^7.1.1, semver@npm:^7.3.5, semver@npm:^7.5.3, semver@npm:^7.5.4, semver@npm:^7.6.0, semver@npm:^7.6.3, semver@npm:^7.7.1":
version: 7.7.2
resolution: "semver@npm:7.7.2"
bin:
@@ -20717,85 +20507,7 @@ __metadata:
languageName: node
linkType: hard
-"sharp@npm:0.34.4":
- version: 0.34.4
- resolution: "sharp@npm:0.34.4"
- dependencies:
- "@img/colour": "npm:^1.0.0"
- "@img/sharp-darwin-arm64": "npm:0.34.4"
- "@img/sharp-darwin-x64": "npm:0.34.4"
- "@img/sharp-libvips-darwin-arm64": "npm:1.2.3"
- "@img/sharp-libvips-darwin-x64": "npm:1.2.3"
- "@img/sharp-libvips-linux-arm": "npm:1.2.3"
- "@img/sharp-libvips-linux-arm64": "npm:1.2.3"
- "@img/sharp-libvips-linux-ppc64": "npm:1.2.3"
- "@img/sharp-libvips-linux-s390x": "npm:1.2.3"
- "@img/sharp-libvips-linux-x64": "npm:1.2.3"
- "@img/sharp-libvips-linuxmusl-arm64": "npm:1.2.3"
- "@img/sharp-libvips-linuxmusl-x64": "npm:1.2.3"
- "@img/sharp-linux-arm": "npm:0.34.4"
- "@img/sharp-linux-arm64": "npm:0.34.4"
- "@img/sharp-linux-ppc64": "npm:0.34.4"
- "@img/sharp-linux-s390x": "npm:0.34.4"
- "@img/sharp-linux-x64": "npm:0.34.4"
- "@img/sharp-linuxmusl-arm64": "npm:0.34.4"
- "@img/sharp-linuxmusl-x64": "npm:0.34.4"
- "@img/sharp-wasm32": "npm:0.34.4"
- "@img/sharp-win32-arm64": "npm:0.34.4"
- "@img/sharp-win32-ia32": "npm:0.34.4"
- "@img/sharp-win32-x64": "npm:0.34.4"
- detect-libc: "npm:^2.1.0"
- semver: "npm:^7.7.2"
- dependenciesMeta:
- "@img/sharp-darwin-arm64":
- optional: true
- "@img/sharp-darwin-x64":
- optional: true
- "@img/sharp-libvips-darwin-arm64":
- optional: true
- "@img/sharp-libvips-darwin-x64":
- optional: true
- "@img/sharp-libvips-linux-arm":
- optional: true
- "@img/sharp-libvips-linux-arm64":
- optional: true
- "@img/sharp-libvips-linux-ppc64":
- optional: true
- "@img/sharp-libvips-linux-s390x":
- optional: true
- "@img/sharp-libvips-linux-x64":
- optional: true
- "@img/sharp-libvips-linuxmusl-arm64":
- optional: true
- "@img/sharp-libvips-linuxmusl-x64":
- optional: true
- "@img/sharp-linux-arm":
- optional: true
- "@img/sharp-linux-arm64":
- optional: true
- "@img/sharp-linux-ppc64":
- optional: true
- "@img/sharp-linux-s390x":
- optional: true
- "@img/sharp-linux-x64":
- optional: true
- "@img/sharp-linuxmusl-arm64":
- optional: true
- "@img/sharp-linuxmusl-x64":
- optional: true
- "@img/sharp-wasm32":
- optional: true
- "@img/sharp-win32-arm64":
- optional: true
- "@img/sharp-win32-ia32":
- optional: true
- "@img/sharp-win32-x64":
- optional: true
- checksum: 10/8e6268e3b0fba7704291684e63c2829963a5ec311d8a8ebbcd32d750c4efb0b01594d925d289ccb5ac0ac373df40fedf5a05a8f331470db799b9c78c48923cba
- languageName: node
- linkType: hard
-
-"sharp@npm:^0.34.4":
+"sharp@npm:0.34.5, sharp@npm:^0.34.4":
version: 0.34.5
resolution: "sharp@npm:0.34.5"
dependencies: