Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions RELEASE.rst
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
Release Notes
=============

Version 0.66.12
---------------

- refactor ocw video etl (#3308)
- fix: update metadata for video pages (#3310)
- Both ETL and webhook should populate OVS resource_category when applicable. (#3313)
- feat: Welcome to learn email on new account setup (#3189)

Version 0.66.10 (Released May 07, 2026)
---------------

Expand Down
10 changes: 9 additions & 1 deletion authentication/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,17 @@ def create_user(username, email, profile_data=None, user_extra=None):
defaults.update({"username": username})

with transaction.atomic():
user, _ = User.objects.get_or_create(email=email, defaults=defaults)
user, created = User.objects.get_or_create(email=email, defaults=defaults)

profile_api.ensure_profile(user, profile_data=profile_data)
if created:
transaction.on_commit(
lambda: user_created_actions(
user=user,
is_new=True,
details=profile_data or {},
)
)

return user

Expand Down
29 changes: 29 additions & 0 deletions authentication/api_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,35 @@ def test_create_user(profile_data):
assert user.profile.name is None


@pytest.mark.django_db(transaction=True)
def test_create_user_triggers_plugins_for_new_users(mocker):
"""create_user should trigger user_created plugins for brand new users."""
mock_pm = mocker.Mock()
mocker.patch("authentication.api.get_plugin_manager", return_value=mock_pm)

user = api.create_user("new-user", "new@localhost", {"name": "New User"})

mock_pm.hook.user_created.assert_called_once_with(
user=user,
user_data={"profile": {"name": "New User"}},
)


@pytest.mark.django_db(transaction=True)
def test_create_user_does_not_retrigger_plugins_for_existing_users(mocker):
"""create_user should not trigger user_created plugins for existing users."""
user = UserFactory.create(email="existing@localhost")
mock_pm = mocker.Mock()
mocker.patch("authentication.api.get_plugin_manager", return_value=mock_pm)

resolved = api.create_user(
"another-username", user.email, {"name": "Existing User"}
)

assert resolved.id == user.id
mock_pm.hook.user_created.assert_not_called()


@pytest.mark.parametrize(
"mock_method",
["profiles.api.ensure_profile"],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -184,8 +184,7 @@ const FeaturedVideo: React.FC<FeaturedVideoProps> = ({
totalVideos,
totalTime,
}) => {
const imageUrl =
video.image?.url ?? video.content_files?.[0]?.image_src ?? null
const imageUrl = video.image?.url ?? null

const duration = video.video?.duration
? formatDurationClockTime(video.video.duration)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -143,9 +143,7 @@ type VideoCardProps = {
const VideoCard: React.FC<VideoCardProps> = ({ resource, href }) => {
const [imgError, setImgError] = useState(false)
const imageUrl = !imgError
? (resource?.image?.url ??
resource.content_files?.[0]?.image_src ??
PLACEHOLDER_IMG)
? (resource?.image?.url ?? PLACEHOLDER_IMG)
: PLACEHOLDER_IMG
const description = resource.description ?? ""
const duration = resource.video?.duration
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -571,7 +571,6 @@ const VideoDetailPage: React.FC<VideoDetailPageProps> = ({
sources[0]?.type === "video/youtube"
? undefined
: (video?.video?.cover_image_url ??
video?.content_files?.[0]?.image_src ??
video?.image?.url ??
undefined)
}
Expand All @@ -581,12 +580,10 @@ const VideoDetailPage: React.FC<VideoDetailPageProps> = ({
ariaLabel={`Video: ${videoTitleLabel}`}
ariaDescribedBy="video-description"
/>
) : video?.image?.url || video?.content_files?.[0]?.image_src ? (
) : video?.image?.url ? (
<ThumbnailWrapper>
<Image
src={
(video?.image?.url ?? video?.content_files?.[0]?.image_src)!
}
src={video.image.url}
alt={videoThumbnailAlt}
fill
sizes="100vw"
Expand Down Expand Up @@ -679,10 +676,7 @@ const VideoDetailPage: React.FC<VideoDetailPageProps> = ({
const itemDuration = item.video?.duration
? formatDurationClockTime(item.video.duration)
: null
const imageUrl =
item.image?.url ??
item.content_files?.[0]?.image_src ??
null
const imageUrl = item.image?.url ?? null
const itemTopicNames = (item.topics ?? [])
.map((topic) => topic.name)
.filter(Boolean)
Expand Down
42 changes: 35 additions & 7 deletions frontends/main/src/app/video-playlist/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import React from "react"
import type { Metadata } from "next"
import { HydrationBoundary, dehydrate } from "@tanstack/react-query"
import { standardizeMetadata } from "@/common/metadata"
import { safeGenerateMetadata, standardizeMetadata } from "@/common/metadata"
import {
learningResourceQueries,
videoPlaylistQueries,
Expand All @@ -10,17 +9,46 @@ import { getQueryClient } from "@/app/getQueryClient"
import VideoPlaylistCollectionPage from "@/app-pages/VideoPlaylistCollectionPage/VideoPlaylistCollectionPage"
import { notFound } from "next/navigation"

export const metadata: Metadata = standardizeMetadata({
title: "Video Playlist",
robots: "noindex, nofollow",
})
export const generateMetadata = async (
props: PageProps<"/video-playlist/[id]">,
) => {
const { id } = await props.params
const playlistId = Number(id)
if (!Number.isInteger(playlistId) || playlistId <= 0) {
notFound()
}
const queryClient = getQueryClient()

return safeGenerateMetadata(async () => {
const [playlist, items] = await Promise.all([
queryClient.fetchQuery(videoPlaylistQueries.detail(playlistId)),
queryClient.fetchQuery(
learningResourceQueries.items(playlistId, {
learning_resource_id: playlistId,
}),
),
])

const firstVideoImage = items?.[0]?.image?.url
const firstVideoImageAlt = items?.[0]?.image?.alt ?? undefined

return standardizeMetadata({
title: playlist.title,
description: playlist.description ?? undefined,
image: firstVideoImage ?? playlist.image?.url,
imageAlt: firstVideoImage
? firstVideoImageAlt
: (playlist.image?.alt ?? undefined),
})
})
}

const Page: React.FC<PageProps<"/video-playlist/[id]">> = async ({
params,
}) => {
const { id } = await params
const playlistId = Number(id)
if (Number.isNaN(playlistId)) {
if (!Number.isInteger(playlistId) || playlistId <= 0) {
notFound()
}
const queryClient = getQueryClient()
Expand Down
28 changes: 23 additions & 5 deletions frontends/main/src/app/video-playlist/detail/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import React from "react"
import type { Metadata } from "next"
import { HydrationBoundary, dehydrate } from "@tanstack/react-query"
import { standardizeMetadata } from "@/common/metadata"
import { safeGenerateMetadata, standardizeMetadata } from "@/common/metadata"
import {
learningResourceQueries,
videoPlaylistQueries,
Expand All @@ -10,9 +9,28 @@ import { getQueryClient } from "@/app/getQueryClient"
import VideoDetailPageRouter from "@/app-pages/VideoPlaylistCollectionPage/VideoDetailPageRouter"
import { notFound } from "next/navigation"

export const metadata: Metadata = standardizeMetadata({
title: "Video Detail",
})
export const generateMetadata = async (
props: PageProps<"/video-playlist/detail/[id]">,
) => {
const { id } = await props.params
const videoId = Number(id)
if (!Number.isInteger(videoId) || videoId <= 0) {
notFound()
}
const queryClient = getQueryClient()

return safeGenerateMetadata(async () => {
const resource = await queryClient.fetchQuery(
learningResourceQueries.detail(videoId),
)
return standardizeMetadata({
title: resource.title,
description: resource.description ?? undefined,
image: resource.image?.url,
imageAlt: resource.image?.alt ?? undefined,
})
})
}

const Page: React.FC<PageProps<"/video-playlist/detail/[id]">> = async ({
params,
Expand Down
26 changes: 6 additions & 20 deletions learning_resources/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -130,27 +130,16 @@ class LearningResourceRelationTypes(TextChoices):


OCW_CONTENT_CATEGORY_PRACTICE_AND_ASSIGNMENT = "Practice & Assignment"
OCW_CONTENT_CATEGORY_OPEN_TEXTBOOKS = "Open Textbooks"
OCW_CONTENT_CATEGORY_LECTURE_VIDEOS = "Lecture Videos"
OCW_CONTENT_CATEGORY_TEACHING_RESOURCES = "Teaching Resources"

VALID_COURSE_CONTENT_CATEGORY_CHOICES = (
(
OCW_CONTENT_CATEGORY_PRACTICE_AND_ASSIGNMENT,
OCW_CONTENT_CATEGORY_PRACTICE_AND_ASSIGNMENT,
),
(OCW_CONTENT_CATEGORY_OPEN_TEXTBOOKS, OCW_CONTENT_CATEGORY_OPEN_TEXTBOOKS),
(OCW_CONTENT_CATEGORY_LECTURE_VIDEOS, OCW_CONTENT_CATEGORY_LECTURE_VIDEOS),
(OCW_CONTENT_CATEGORY_TEACHING_RESOURCES, OCW_CONTENT_CATEGORY_TEACHING_RESOURCES),
)
OCW_CONTENT_CATEGORY_OPEN_TEXTBOOKS = "Open Textbook"
OCW_CONTENT_CATEGORY_LECTURE_VIDEOS = "Lecture Video"

VIDEO_CONTENT_CATEGORIES = [
OCW_CONTENT_CATEGORY_LECTURE_VIDEOS,
OCW_CONTENT_CATEGORY_TEACHING_RESOURCES,
]
OCW_INSTRUCTOR_INSIGHTS_TAG = "Instructor Insights"

VIDEO_SHORT_RESOURCE_CATEGORY = "Video Short"

OCW_PLAYLIST_VIDEO_THRESHOLD = 0.6


OCW_COURSE_CONTENT_CATEGORY_MAPPING = {
"Exams": OCW_CONTENT_CATEGORY_PRACTICE_AND_ASSIGNMENT,
"Exams Solutions": OCW_CONTENT_CATEGORY_PRACTICE_AND_ASSIGNMENT,
Expand All @@ -166,9 +155,6 @@ class LearningResourceRelationTypes(TextChoices):
"Activity Assignments": OCW_CONTENT_CATEGORY_PRACTICE_AND_ASSIGNMENT,
"Written Assignments": OCW_CONTENT_CATEGORY_PRACTICE_AND_ASSIGNMENT,
OCW_CONTENT_CATEGORY_OPEN_TEXTBOOKS: OCW_CONTENT_CATEGORY_OPEN_TEXTBOOKS,
"Lecture Videos": OCW_CONTENT_CATEGORY_LECTURE_VIDEOS,
"Problem-solving Videos": OCW_CONTENT_CATEGORY_LECTURE_VIDEOS,
"Instructor Insights": OCW_CONTENT_CATEGORY_TEACHING_RESOURCES,
}


Expand Down
Loading
Loading