Merge pull request #1609 from pateljannat/track-video-watch-duration

feat: video watch time tracking
This commit is contained in:
Jannat Patel
2025-07-02 10:53:56 +05:30
committed by GitHub
20 changed files with 797 additions and 119 deletions

View File

@@ -102,6 +102,7 @@ declare module 'vue' {
UserAvatar: typeof import('./src/components/UserAvatar.vue')['default']
UserDropdown: typeof import('./src/components/UserDropdown.vue')['default']
VideoBlock: typeof import('./src/components/VideoBlock.vue')['default']
VideoStatistics: typeof import('./src/components/Modals/VideoStatistics.vue')['default']
ZoomAccountModal: typeof import('./src/components/Modals/ZoomAccountModal.vue')['default']
ZoomSettings: typeof import('./src/components/Settings/ZoomSettings.vue')['default']
}

View File

@@ -225,7 +225,7 @@ import {
IntermediateStepModal,
} from 'frappe-ui/frappe'
const { user, sidebarSettings } = sessionStore()
const { user } = sessionStore()
const { userResource } = usersStore()
let sidebarStore = useSidebar()
const socket = inject('$socket')
@@ -236,6 +236,7 @@ const isModerator = ref(false)
const isInstructor = ref(false)
const pageToEdit = ref(null)
const settingsStore = useSettings()
const { sidebarSettings } = settingsStore
const showOnboarding = ref(false)
const showIntermediateModal = ref(false)
const currentStep = ref({})

View File

@@ -58,11 +58,13 @@ import { getSidebarLinks } from '@/utils'
import { useRouter } from 'vue-router'
import { watch, ref, onMounted } from 'vue'
import { sessionStore } from '@/stores/session'
import { useSettings } from '@/stores/settings'
import { usersStore } from '@/stores/user'
import * as icons from 'lucide-vue-next'
const { logout, user, sidebarSettings } = sessionStore()
const { logout, user } = sessionStore()
let { isLoggedIn } = sessionStore()
const { sidebarSettings } = useSettings()
const router = useRouter()
let { userResource } = usersStore()
const sidebarLinks = ref(getSidebarLinks())

View File

@@ -0,0 +1,216 @@
<template>
<Dialog
v-model="show"
:options="{
size: '4xl',
title: __('Video Statistics for {0}').format(lessonTitle),
}"
>
<template #body-content>
<div class="text-base">
<TabButtons
v-if="tabs.length > 1"
:buttons="tabs"
v-model="currentTab"
class="w-fit"
/>
<div v-if="currentTab" class="mt-8">
<div class="grid grid-cols-[55%,40%] gap-5">
<div class="space-y-5 border rounded-md p-2 pt-4">
<div class="grid grid-cols-[70%,30%] text-sm text-ink-gray-5">
<div class="px-4">
{{ __('Member') }}
</div>
<div class="text-center">
{{ __('Watch Time') }}
</div>
</div>
<div
v-for="row in currentTabData"
class="hover:bg-surface-gray-1 cursor-pointer rounded-md py-1 px-2"
>
<router-link
:to="{
name: 'Profile',
params: { username: row.member_username },
}"
>
<div class="grid grid-cols-[70%,30%] items-center">
<div class="flex items-center space-x-2">
<Avatar
:image="row.member_image"
:label="row.member_name"
size="xl"
/>
<div class="space-y-1">
<div class="font-medium">
{{ row.member_name }}
</div>
<div class="text-sm text-ink-gray-6">
{{ row.member }}
</div>
</div>
</div>
<div class="text-center text-sm">
{{ parseFloat(row.watch_time).toFixed(2) }}
</div>
</div>
</router-link>
</div>
</div>
<div class="space-y-5">
<NumberChart
class="border rounded-md"
:config="{
title: __('Average Watch Time (seconds)'),
value: averageWatchTime,
}"
/>
<div v-if="isPlyrSource">
<div class="video-player" :src="currentTab"></div>
</div>
<VideoBlock v-else :file="currentTab" />
</div>
</div>
</div>
</div>
</template>
</Dialog>
</template>
<script setup lang="ts">
import {
Avatar,
createListResource,
Dialog,
NumberChart,
TabButtons,
} from 'frappe-ui'
import { computed, ref, watch } from 'vue'
import { enablePlyr } from '@/utils'
import VideoBlock from '@/components/VideoBlock.vue'
const show = defineModel<boolean | undefined>()
const currentTab = ref<string>('')
const props = defineProps<{
lessonName?: string
lessonTitle?: string
}>()
const statistics = createListResource({
doctype: 'LMS Video Watch Duration',
filters: {
lesson: props.lessonName,
},
fields: [
'name',
'member',
'member_name',
'member_image',
'member_username',
'source',
'watch_time',
],
cache: ['videoStatistics', props.lessonName],
onSuccess() {
currentTab.value = Object.keys(statisticsData.value)[0]
},
})
watch(
() => props.lessonName,
() => {
if (props.lessonName) {
statistics.filters.lesson = props.lessonName
statistics.reload()
}
}
)
watch(show, () => {
if (show.value) {
enablePlyr()
}
})
const statisticsData = computed(() => {
const grouped = <Record<string, any[]>>{}
statistics.data.forEach((item: { source: string }) => {
if (!grouped[item.source]) {
grouped[item.source] = []
}
grouped[item.source].push(item)
})
return grouped
})
const averageWatchTime = computed(() => {
let totalWatchTime = 0
currentTabData.value.forEach((item: { watch_time: string }) => {
totalWatchTime += parseFloat(item.watch_time)
})
return totalWatchTime / currentTabData.value.length
})
const currentTabData = computed(() => {
return statisticsData.value[currentTab.value] || []
})
const isPlyrSource = computed(() => {
return (
currentTab.value.includes('youtube') || currentTab.value.includes('vimeo')
)
})
const provider = computed(() => {
if (currentTab.value.includes('youtube')) {
return 'youtube'
} else if (currentTab.value.includes('vimeo')) {
return 'vimeo'
}
return ''
})
const embedURL = computed(() => {
if (isPlyrSource.value) {
return currentTab.value.replace('watch?v=', 'embed/')
}
return ''
})
const tabs = computed(() => {
return Object.keys(statisticsData.value).map((source, index) => ({
label: __(`Video ${index + 1}`),
value: source,
}))
})
</script>
<style>
.plyr__volume input[type='range'] {
display: none;
}
.plyr__control--overlaid {
background: radial-gradient(
circle,
rgba(0, 0, 0, 0.4) 0%,
rgba(0, 0, 0, 0.5) 50%
);
}
.plyr__control:hover {
background: none;
}
.plyr--video {
border: 1px solid theme('colors.gray.200');
border-radius: 8px;
}
:root {
--plyr-range-fill-background: white;
--plyr-video-control-background-hover: transparent;
}
</style>

View File

@@ -130,6 +130,13 @@ const tabsStructure = computed(() => {
label: 'General',
icon: 'Wrench',
fields: [
{
label: 'Allow Guest Access',
name: 'allow_guest_access',
description:
'If enabled, users can access the course and batch lists without logging in.',
type: 'checkbox',
},
{
label: 'Enable Learning Paths',
name: 'enable_learning_paths',
@@ -138,11 +145,11 @@ const tabsStructure = computed(() => {
type: 'checkbox',
},
{
label: 'Allow Guest Access',
name: 'allow_guest_access',
description:
'If enabled, users can access the course and batch lists without logging in.',
label: 'Prevent Skipping Videos',
name: 'prevent_skipping_videos',
type: 'checkbox',
description:
'If enabled, users will no able to move forward in a video',
},
{
label: 'Send calendar invite for evaluations',
@@ -154,6 +161,14 @@ const tabsStructure = computed(() => {
{
type: 'Column Break',
},
{
label: 'Livecode URL',
name: 'livecode_url',
doctype: 'Livecode URL',
type: 'text',
description:
'https://docs.frappe.io/learning/falcon-self-hosting-guide',
},
{
label: 'Batch Confirmation Email Template',
name: 'batch_confirmation_template',
@@ -166,14 +181,6 @@ const tabsStructure = computed(() => {
doctype: 'Email Template',
type: 'Link',
},
{
label: 'Livecode URL',
name: 'livecode_url',
doctype: 'Livecode URL',
type: 'text',
description:
'https://docs.frappe.io/learning/falcon-self-hosting-guide',
},
{
label: 'Unsplash Access Key',
name: 'unsplash_access_key',

View File

@@ -27,9 +27,9 @@
oncontextmenu="return false"
class="rounded-md border border-gray-100 cursor-pointer"
ref="videoRef"
>
<source :src="fileURL" :type="type" />
</video>
:src="fileURL"
:type="type"
></video>
<div
v-if="!playing"
class="absolute inset-0 flex items-center justify-center cursor-pointer"
@@ -155,6 +155,7 @@ import { ref, onMounted, computed, watch } from 'vue'
import { Pause, Maximize, Volume2, VolumeX } from 'lucide-vue-next'
import { Button, Dialog } from 'frappe-ui'
import { formatSeconds, formatTimestamp } from '@/utils'
import { useSettings } from '@/stores/settings'
import Play from '@/components/Icons/Play.vue'
import QuizInVideo from '@/components/Modals/QuizInVideo.vue'
@@ -170,6 +171,7 @@ const showQuizLoader = ref(false)
const quizLoadTimer = ref(0)
const currentQuiz = ref(null)
const nextQuiz = ref({})
const { preventSkippingVideos } = useSettings()
const props = defineProps({
file: {
@@ -181,7 +183,7 @@ const props = defineProps({
default: 'video/mp4',
},
readOnly: {
type: String,
type: Boolean,
default: true,
},
quizzes: {
@@ -190,6 +192,7 @@ const props = defineProps({
},
saveQuizzes: {
type: Function,
default: () => {},
},
})
@@ -295,6 +298,11 @@ const toggleMute = () => {
}
const changeCurrentTime = () => {
if (
preventSkippingVideos.data &&
currentTime.value > videoRef.value.currentTime
)
return
videoRef.value.currentTime = currentTime.value
updateNextQuiz()
}

View File

@@ -12,6 +12,12 @@
</template>
</Button>
</Tooltip>
<Button v-if="canSeeStats()" @click="showVideoStats()">
<template #prefix>
<TrendingUp class="size-4 stroke-1.5" />
</template>
{{ __('Video Statistics') }}
</Button>
<CertificationLinks :courseName="courseName" />
</div>
</header>
@@ -100,26 +106,15 @@
<MessageCircleQuestion class="w-4 h-4 stroke-1.5" />
</template>
</Button>
<router-link
v-if="lesson.data.prev"
:to="{
name: 'Lesson',
params: {
courseName: courseName,
chapterNumber: lesson.data.prev.split('.')[0],
lessonNumber: lesson.data.prev.split('.')[1],
},
}"
>
<Button>
<template #prefix>
<ChevronLeft class="w-4 h-4 stroke-1" />
</template>
<span>
{{ __('Previous') }}
</span>
</Button>
</router-link>
<Button v-if="lesson.data.prev" @click="switchLesson('prev')">
<template #prefix>
<ChevronLeft class="w-4 h-4 stroke-1" />
</template>
<span>
{{ __('Previous') }}
</span>
</Button>
<router-link
v-if="allowEdit()"
:to="{
@@ -135,26 +130,16 @@
{{ __('Edit') }}
</Button>
</router-link>
<router-link
v-if="lesson.data.next"
:to="{
name: 'Lesson',
params: {
courseName: courseName,
chapterNumber: lesson.data.next.split('.')[0],
lessonNumber: lesson.data.next.split('.')[1],
},
}"
>
<Button>
<template #suffix>
<ChevronRight class="w-4 h-4 stroke-1" />
</template>
<span>
{{ __('Next') }}
</span>
</Button>
</router-link>
<Button v-if="lesson.data.next" @click="switchLesson('next')">
<template #suffix>
<ChevronRight class="w-4 h-4 stroke-1" />
</template>
<span>
{{ __('Next') }}
</span>
</Button>
<router-link
v-else
:to="{
@@ -262,13 +247,19 @@
</div>
</div>
</div>
<VideoStatistics
v-model="showStatsDialog"
:lessonName="lesson.data?.name"
:lessonTitle="lesson.data?.title"
/>
</template>
<script setup>
import {
createResource,
Badge,
Breadcrumbs,
Button,
call,
createResource,
Tooltip,
usePageMeta,
} from 'frappe-ui'
@@ -292,6 +283,7 @@ import {
Focus,
Info,
MessageCircleQuestion,
TrendingUp,
} from 'lucide-vue-next'
import Discussions from '@/components/Discussions.vue'
import { getEditorTools, enablePlyr } from '@/utils'
@@ -302,6 +294,7 @@ import LessonContent from '@/components/LessonContent.vue'
import CourseInstructors from '@/components/CourseInstructors.vue'
import ProgressBar from '@/components/ProgressBar.vue'
import CertificationLinks from '@/components/CertificationLinks.vue'
import VideoStatistics from '@/components/Modals/VideoStatistics.vue'
const user = inject('$user')
const socket = inject('$socket')
@@ -313,11 +306,13 @@ const instructorEditor = ref(null)
const lessonProgress = ref(0)
const lessonContainer = ref(null)
const zenModeEnabled = ref(false)
const showStatsDialog = ref(false)
const hasQuiz = ref(false)
const discussionsContainer = ref(null)
const timer = ref(0)
const { brand } = sessionStore()
const sidebarStore = useSidebar()
const plyrSources = ref([])
let timerInterval
const props = defineProps({
@@ -337,7 +332,6 @@ const props = defineProps({
onMounted(() => {
startTimer()
console.log(sidebarStore.isSidebarCollapsed)
sidebarStore.isSidebarCollapsed = true
document.addEventListener('fullscreenchange', attachFullscreenEvent)
socket.on('update_lesson_progress', (data) => {
@@ -362,6 +356,7 @@ const attachFullscreenEvent = () => {
onBeforeUnmount(() => {
document.removeEventListener('fullscreenchange', attachFullscreenEvent)
sidebarStore.isSidebarCollapsed = false
trackVideoWatchDuration()
})
const lesson = createResource({
@@ -457,36 +452,157 @@ const breadcrumbs = computed(() => {
return items
})
const switchLesson = (direction) => {
trackVideoWatchDuration()
let lessonIndex =
direction === 'prev'
? lesson.data.prev.split('.')
: lesson.data.next.split('.')
router.push({
name: 'Lesson',
params: {
courseName: props.courseName,
chapterNumber: lessonIndex[0],
lessonNumber: lessonIndex[1],
},
})
}
watch(
[() => route.params.chapterNumber, () => route.params.lessonNumber],
(
async (
[newChapterNumber, newLessonNumber],
[oldChapterNumber, oldLessonNumber]
) => {
if (newChapterNumber || newLessonNumber) {
editor.value = null
instructorEditor.value = null
allowDiscussions.value = false
lesson.submit({
chapter: newChapterNumber,
lesson: newLessonNumber,
})
clearInterval(timerInterval)
timer.value = 0
plyrSources.value = []
await nextTick()
resetLessonState(newChapterNumber, newLessonNumber)
startTimer()
enablePlyr()
}
}
)
const resetLessonState = (newChapterNumber, newLessonNumber) => {
editor.value = null
instructorEditor.value = null
allowDiscussions.value = false
lesson.submit({
chapter: newChapterNumber,
lesson: newLessonNumber,
})
clearInterval(timerInterval)
timer.value = 0
}
const trackVideoWatchDuration = () => {
if (!lesson.data.membership) return
let videoDetails = getVideoDetails()
videoDetails = videoDetails.concat(getPlyrSourceDetails())
call('lms.lms.api.track_video_watch_duration', {
lesson: lesson.data.name,
videos: videoDetails,
})
}
const getVideoDetails = () => {
let details = []
const videos = document.querySelectorAll('video')
if (videos.length > 0) {
videos.forEach((video) => {
if (video.currentTime == video.duration) markProgress()
details.push({
source: video.src,
watch_time: video.currentTime,
})
})
}
return details
}
const getPlyrSourceDetails = () => {
let details = []
plyrSources.value.forEach((source) => {
if (source.currentTime == source.duration) markProgress()
let src = cleanYouTubeUrl(source.source)
details.push({
source: src,
watch_time: source.currentTime,
})
})
return details
}
const cleanYouTubeUrl = (url) => {
if (!url) return url
const urlObj = new URL(url)
urlObj.searchParams.delete('t')
return urlObj.toString()
}
watch(
() => lesson.data,
(data) => {
async (data) => {
setupLesson(data)
enablePlyr()
getPlyrSource()
if (data.icon == 'icon-youtube') clearInterval(timerInterval)
}
)
const getPlyrSource = async () => {
await nextTick()
if (plyrSources.value.length == 0) {
plyrSources.value = await enablePlyr()
}
updateVideoWatchDuration()
}
const updateVideoWatchDuration = () => {
if (lesson.data.videos && lesson.data.videos.length > 0) {
lesson.data.videos.forEach((video) => {
if (video.source.includes('youtube') || video.source.includes('vimeo')) {
updatePlyrVideoTime(video)
} else {
updateVideoTime(video)
}
})
}
}
const updatePlyrVideoTime = (video) => {
plyrSources.value.forEach((plyrSource) => {
let lastWatchedTime = 0
let isSeeking = false
plyrSource.on('ready', () => {
if (plyrSource.source === video.source) {
plyrSource.embed.seekTo(video.watch_time, true)
plyrSource.play()
plyrSource.pause()
}
})
})
}
const updateVideoTime = (video) => {
const videos = document.querySelectorAll('video')
if (videos.length > 0) {
videos.forEach((vid) => {
if (vid.src === video.source) {
let watch_time = video.watch_time < vid.duration ? video.watch_time : 0
if (vid.readyState >= 1) {
vid.currentTime = watch_time
} else {
vid.addEventListener('loadedmetadata', () => {
vid.currentTime = watch_time
})
}
}
})
}
}
const startTimer = () => {
timerInterval = setInterval(() => {
timer.value++
@@ -553,6 +669,15 @@ const enrollStudent = () => {
)
}
const canSeeStats = () => {
if (user.data?.is_moderator || user.data?.is_instructor) return true
return false
}
const showVideoStats = () => {
showStatsDialog.value = true
}
const canGoZen = () => {
if (
user.data?.is_moderator ||

View File

@@ -54,12 +54,6 @@ export const sessionStore = defineStore('lms-session', () => {
},
})
const sidebarSettings = createResource({
url: 'lms.lms.api.get_sidebar_settings',
cache: 'Sidebar Settings',
auto: false,
})
const livecodeURL = createResource({
url: 'frappe.client.get_single_value',
params: {
@@ -77,7 +71,6 @@ export const sessionStore = defineStore('lms-session', () => {
logout,
brand,
branding,
sidebarSettings,
livecodeURL,
}
})

View File

@@ -9,21 +9,38 @@ export const useSettings = defineStore('settings', () => {
const activeTab = ref(null)
const learningPaths = createResource({
url: 'lms.lms.api.is_learning_path_enabled',
url: 'lms.lms.api.get_lms_setting',
params: { field: 'enable_learning_paths' },
auto: true,
cache: ['learningPath'],
})
const allowGuestAccess = createResource({
url: 'lms.lms.api.is_guest_allowed',
url: 'lms.lms.api.get_lms_setting',
params: { field: 'allow_guest_access' },
auto: true,
cache: ['allowGuestAccess'],
})
const preventSkippingVideos = createResource({
url: 'lms.lms.api.get_lms_setting',
params: { field: 'prevent_skipping_videos' },
auto: true,
cache: ['preventSkippingVideos'],
})
const sidebarSettings = createResource({
url: 'lms.lms.api.get_sidebar_settings',
cache: 'Sidebar Settings',
auto: false,
})
return {
isSettingsOpen,
activeTab,
learningPaths,
allowGuestAccess,
preventSkippingVideos,
sidebarSettings,
}
})

View File

@@ -1,4 +1,3 @@
import { watch } from 'vue'
import { call, toast } from 'frappe-ui'
import { useTimeAgo } from '@vueuse/core'
import { Quiz } from '@/utils/quiz'
@@ -531,33 +530,85 @@ export const canCreateCourse = () => {
)
}
export const enablePlyr = () => {
setTimeout(() => {
const videoElement = document.getElementsByClassName('video-player')
if (videoElement.length === 0) return
export const enablePlyr = async () => {
await wait(500)
Array.from(videoElement).forEach((video) => {
const src = video.getAttribute('src')
if (src) {
let videoID = src.split('/').pop()
video.setAttribute('data-plyr-embed-id', videoID)
}
new Plyr(video, {
youtube: {
noCookie: true,
},
controls: [
'play-large',
'play',
'progress',
'current-time',
'mute',
'volume',
'fullscreen',
],
})
}, 500)
const players = []
const videoElements = document.getElementsByClassName('video-player')
if (videoElements.length === 0) return players
Array.from(videoElements).forEach((video) => {
setupPlyrForVideo(video, players)
})
return players
}
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
const setupPlyrForVideo = (video, players) => {
const src = video.getAttribute('src') || video.getAttribute('data-src')
if (src) {
const videoID = extractYouTubeId(src)
video.setAttribute('data-plyr-provider', 'youtube')
video.setAttribute('data-plyr-embed-id', videoID)
}
let controls = [
'play-large',
'play',
'progress',
'current-time',
'mute',
'volume',
'fullscreen',
]
const player = new Plyr(video, {
youtube: { noCookie: true },
controls: controls,
listeners: {
seek: function customSeekBehavior(e) {
const current_time = player.currentTime
const newTime = getTargetTime(player, e)
if (
useSettings().preventSkippingVideos.data &&
parseFloat(newTime) > current_time
) {
e.preventDefault()
player.currentTime = current_time
return false
}
},
},
})
players.push(player)
}
const getTargetTime = (plyr, input) => {
if (
typeof input === 'object' &&
(input.type === 'input' || input.type === 'change')
) {
return (input.target.value / input.target.max) * plyr.duration
} else {
return Number(input)
}
}
const extractYouTubeId = (url) => {
try {
const parsedUrl = new URL(url)
return (
parsedUrl.searchParams.get('v') ||
parsedUrl.pathname.split('/').pop()
)
} catch {
return url.split('/').pop()
}
}
export const openSettings = (category, close = null) => {
@@ -567,7 +618,6 @@ export const openSettings = (category, close = null) => {
}
settingsStore.activeTab = category
settingsStore.isSettingsOpen = true
console.log(settingsStore.activeTab, settingsStore.isSettingsOpen)
}
export const cleanError = (message) => {

View File

@@ -553,6 +553,7 @@ def get_sidebar_settings():
"jobs",
"statistics",
"notifications",
"programming_exercises",
]
for item in items:
sidebar_items[item] = lms_settings.get(item)
@@ -1304,13 +1305,8 @@ def get_notifications(filters):
@frappe.whitelist(allow_guest=True)
def is_guest_allowed():
return frappe.get_cached_value("LMS Settings", None, "allow_guest_access")
@frappe.whitelist(allow_guest=True)
def is_learning_path_enabled():
return frappe.get_cached_value("LMS Settings", None, "enable_learning_paths")
def get_lms_setting(field):
return frappe.get_cached_value("LMS Settings", None, field)
@frappe.whitelist()
@@ -1559,3 +1555,40 @@ def update_test_cases(test_cases, submission):
}
)
test_case.insert()
@frappe.whitelist()
def track_video_watch_duration(lesson, videos):
"""
Track the watch duration of videos in a lesson.
"""
if not isinstance(videos, list):
videos = json.loads(videos)
for video in videos:
filters = {
"lesson": lesson,
"source": video.get("source"),
"member": frappe.session.user,
}
existing_record = frappe.db.get_value(
"LMS Video Watch Duration", filters, ["name", "watch_time"], as_dict=True
)
if existing_record and existing_record.watch_time < video.get("watch_time"):
frappe.db.set_value(
"LMS Video Watch Duration",
filters,
"watch_time",
video.get("watch_time"),
)
elif not existing_record:
track_new_watch_time(lesson, video)
def track_new_watch_time(lesson, video):
doc = frappe.new_doc("LMS Video Watch Duration")
doc.lesson = lesson
doc.source = video.get("source")
doc.watch_time = video.get("watch_time")
doc.member = frappe.session.user
doc.save()

View File

@@ -12,6 +12,8 @@
"column_break_zdel",
"allow_guest_access",
"enable_learning_paths",
"prevent_skipping_videos",
"column_break_bjis",
"unsplash_access_key",
"livecode_url",
"section_break_szgq",
@@ -73,7 +75,6 @@
"default": "https://livecode.dev.fossunited.org",
"fieldname": "livecode_url",
"fieldtype": "Data",
"hidden": 1,
"label": "LiveCode URL"
},
{
@@ -407,6 +408,16 @@
"fieldtype": "Check",
"label": "Certified Members"
},
{
"default": "0",
"fieldname": "prevent_skipping_videos",
"fieldtype": "Check",
"label": "Prevent Skipping Videos"
},
{
"fieldname": "column_break_bjis",
"fieldtype": "Column Break"
},
{
"default": "1",
"fieldname": "programming_exercises",
@@ -418,7 +429,7 @@
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
"modified": "2025-05-30 19:02:51.381668",
"modified": "2025-07-01 17:01:58.466698",
"modified_by": "sayali@frappe.io",
"module": "LMS",
"name": "LMS Settings",

View File

@@ -0,0 +1,8 @@
// Copyright (c) 2025, Frappe and contributors
// For license information, please see license.txt
// frappe.ui.form.on("LMS Video Watch Duration", {
// refresh(frm) {
// },
// });

View File

@@ -0,0 +1,160 @@
{
"actions": [],
"allow_rename": 1,
"creation": "2025-06-30 13:00:22.655432",
"doctype": "DocType",
"engine": "InnoDB",
"field_order": [
"lesson",
"chapter",
"course",
"column_break_tmwj",
"member",
"member_name",
"member_image",
"member_username",
"section_break_fywc",
"source",
"column_break_uuyv",
"watch_time"
],
"fields": [
{
"fieldname": "lesson",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Lesson",
"options": "Course Lesson",
"reqd": 1
},
{
"fetch_from": "lesson.chapter",
"fieldname": "chapter",
"fieldtype": "Link",
"label": "Chapter",
"options": "Course Chapter",
"read_only": 1
},
{
"fetch_from": "lesson.course",
"fieldname": "course",
"fieldtype": "Link",
"label": "Course",
"options": "LMS Course",
"read_only": 1
},
{
"fieldname": "column_break_tmwj",
"fieldtype": "Column Break"
},
{
"fieldname": "member",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Member",
"options": "User",
"reqd": 1
},
{
"fetch_from": "member.full_name",
"fieldname": "member_name",
"fieldtype": "Data",
"label": "Member Name"
},
{
"fetch_from": "member.user_image",
"fieldname": "member_image",
"fieldtype": "Attach Image",
"label": "Member Image"
},
{
"fetch_from": "member.username",
"fieldname": "member_username",
"fieldtype": "Data",
"label": "Member Username"
},
{
"fieldname": "section_break_fywc",
"fieldtype": "Section Break"
},
{
"fieldname": "source",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Source",
"reqd": 1
},
{
"fieldname": "column_break_uuyv",
"fieldtype": "Column Break"
},
{
"fieldname": "watch_time",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Watch Time",
"reqd": 1
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"links": [],
"modified": "2025-06-30 16:57:10.561660",
"modified_by": "sayali@frappe.io",
"module": "LMS",
"name": "LMS Video Watch Duration",
"owner": "Administrator",
"permissions": [
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "System Manager",
"share": 1,
"write": 1
},
{
"create": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "LMS Student",
"share": 1,
"write": 1
},
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Moderator",
"share": 1,
"write": 1
},
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Course Creator",
"share": 1,
"write": 1
}
],
"row_format": "Dynamic",
"sort_field": "creation",
"sort_order": "DESC",
"states": []
}

View File

@@ -0,0 +1,9 @@
# Copyright (c) 2025, Frappe and contributors
# For license information, please see license.txt
# import frappe
from frappe.model.document import Document
class LMSVideoWatchDuration(Document):
pass

View File

@@ -0,0 +1,21 @@
# Copyright (c) 2025, Frappe and Contributors
# See license.txt
# import frappe
from frappe.tests import IntegrationTestCase
# On IntegrationTestCase, the doctype test records and all
# link-field test record dependencies are recursively loaded
# Use these module variables to add/remove to/from that list
EXTRA_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
IGNORE_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
class IntegrationTestLMSVideoWatchDuration(IntegrationTestCase):
"""
Integration tests for LMSVideoWatchDuration.
Use this class for testing interactions between multiple components.
"""
pass

View File

@@ -1320,13 +1320,23 @@ def get_lesson(course, chapter, lesson):
lesson_details.progress = progress
lesson_details.prev = neighbours["prev"]
lesson_details.membership = membership
lesson_details.icon = get_lesson_icon(lesson_details.body, lesson_details.content)
lesson_details.instructors = get_instructors("LMS Course", course)
lesson_details.course_title = course_info.title
lesson_details.paid_certificate = course_info.paid_certificate
lesson_details.disable_self_learning = course_info.disable_self_learning
lesson_details.videos = get_video_details(lesson_name)
return lesson_details
def get_video_details(lesson_name):
return frappe.get_all(
"LMS Video Watch Duration",
{"lesson": lesson_name, "member": frappe.session.user},
["source", "watch_time"],
)
def get_neighbour_lesson(course, chapter, lesson):
numbers = []
current = f"{chapter}.{lesson}"

View File

@@ -108,4 +108,5 @@ lms.patches.v2_0.move_zoom_settings #20-05-2025
lms.patches.v2_0.link_zoom_account_to_live_class
lms.patches.v2_0.link_zoom_account_to_batch
lms.patches.v2_0.sidebar_for_certified_members
lms.patches.v2_0.move_batch_instructors_to_evaluators
lms.patches.v2_0.move_batch_instructors_to_evaluators
lms.patches.v2_0.enable_programming_exercises_in_sidebar

View File

@@ -0,0 +1,5 @@
import frappe
def execute():
frappe.db.set_single_value("LMS Settings", "programming_exercises", True)