Merge branch 'develop' into patch-1
This commit is contained in:
1
frontend/components.d.ts
vendored
1
frontend/components.d.ts
vendored
@@ -102,6 +102,7 @@ declare module 'vue' {
|
|||||||
UserAvatar: typeof import('./src/components/UserAvatar.vue')['default']
|
UserAvatar: typeof import('./src/components/UserAvatar.vue')['default']
|
||||||
UserDropdown: typeof import('./src/components/UserDropdown.vue')['default']
|
UserDropdown: typeof import('./src/components/UserDropdown.vue')['default']
|
||||||
VideoBlock: typeof import('./src/components/VideoBlock.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']
|
ZoomAccountModal: typeof import('./src/components/Modals/ZoomAccountModal.vue')['default']
|
||||||
ZoomSettings: typeof import('./src/components/Settings/ZoomSettings.vue')['default']
|
ZoomSettings: typeof import('./src/components/Settings/ZoomSettings.vue')['default']
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -225,7 +225,7 @@ import {
|
|||||||
IntermediateStepModal,
|
IntermediateStepModal,
|
||||||
} from 'frappe-ui/frappe'
|
} from 'frappe-ui/frappe'
|
||||||
|
|
||||||
const { user, sidebarSettings } = sessionStore()
|
const { user } = sessionStore()
|
||||||
const { userResource } = usersStore()
|
const { userResource } = usersStore()
|
||||||
let sidebarStore = useSidebar()
|
let sidebarStore = useSidebar()
|
||||||
const socket = inject('$socket')
|
const socket = inject('$socket')
|
||||||
@@ -236,6 +236,7 @@ const isModerator = ref(false)
|
|||||||
const isInstructor = ref(false)
|
const isInstructor = ref(false)
|
||||||
const pageToEdit = ref(null)
|
const pageToEdit = ref(null)
|
||||||
const settingsStore = useSettings()
|
const settingsStore = useSettings()
|
||||||
|
const { sidebarSettings } = settingsStore
|
||||||
const showOnboarding = ref(false)
|
const showOnboarding = ref(false)
|
||||||
const showIntermediateModal = ref(false)
|
const showIntermediateModal = ref(false)
|
||||||
const currentStep = ref({})
|
const currentStep = ref({})
|
||||||
|
|||||||
@@ -58,11 +58,13 @@ import { getSidebarLinks } from '@/utils'
|
|||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { watch, ref, onMounted } from 'vue'
|
import { watch, ref, onMounted } from 'vue'
|
||||||
import { sessionStore } from '@/stores/session'
|
import { sessionStore } from '@/stores/session'
|
||||||
|
import { useSettings } from '@/stores/settings'
|
||||||
import { usersStore } from '@/stores/user'
|
import { usersStore } from '@/stores/user'
|
||||||
import * as icons from 'lucide-vue-next'
|
import * as icons from 'lucide-vue-next'
|
||||||
|
|
||||||
const { logout, user, sidebarSettings } = sessionStore()
|
const { logout, user } = sessionStore()
|
||||||
let { isLoggedIn } = sessionStore()
|
let { isLoggedIn } = sessionStore()
|
||||||
|
const { sidebarSettings } = useSettings()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
let { userResource } = usersStore()
|
let { userResource } = usersStore()
|
||||||
const sidebarLinks = ref(getSidebarLinks())
|
const sidebarLinks = ref(getSidebarLinks())
|
||||||
|
|||||||
216
frontend/src/components/Modals/VideoStatistics.vue
Normal file
216
frontend/src/components/Modals/VideoStatistics.vue
Normal 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>
|
||||||
@@ -130,6 +130,13 @@ const tabsStructure = computed(() => {
|
|||||||
label: 'General',
|
label: 'General',
|
||||||
icon: 'Wrench',
|
icon: 'Wrench',
|
||||||
fields: [
|
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',
|
label: 'Enable Learning Paths',
|
||||||
name: 'enable_learning_paths',
|
name: 'enable_learning_paths',
|
||||||
@@ -138,11 +145,11 @@ const tabsStructure = computed(() => {
|
|||||||
type: 'checkbox',
|
type: 'checkbox',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Allow Guest Access',
|
label: 'Prevent Skipping Videos',
|
||||||
name: 'allow_guest_access',
|
name: 'prevent_skipping_videos',
|
||||||
description:
|
|
||||||
'If enabled, users can access the course and batch lists without logging in.',
|
|
||||||
type: 'checkbox',
|
type: 'checkbox',
|
||||||
|
description:
|
||||||
|
'If enabled, users will no able to move forward in a video',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Send calendar invite for evaluations',
|
label: 'Send calendar invite for evaluations',
|
||||||
@@ -154,6 +161,14 @@ const tabsStructure = computed(() => {
|
|||||||
{
|
{
|
||||||
type: 'Column Break',
|
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',
|
label: 'Batch Confirmation Email Template',
|
||||||
name: 'batch_confirmation_template',
|
name: 'batch_confirmation_template',
|
||||||
@@ -166,14 +181,6 @@ const tabsStructure = computed(() => {
|
|||||||
doctype: 'Email Template',
|
doctype: 'Email Template',
|
||||||
type: 'Link',
|
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',
|
label: 'Unsplash Access Key',
|
||||||
name: 'unsplash_access_key',
|
name: 'unsplash_access_key',
|
||||||
@@ -300,6 +307,11 @@ const tabsStructure = computed(() => {
|
|||||||
name: 'batches',
|
name: 'batches',
|
||||||
type: 'checkbox',
|
type: 'checkbox',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: 'Programming Exercises',
|
||||||
|
name: 'programming_exercises',
|
||||||
|
type: 'checkbox',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: 'Certified Members',
|
label: 'Certified Members',
|
||||||
name: 'certified_members',
|
name: 'certified_members',
|
||||||
|
|||||||
@@ -27,9 +27,9 @@
|
|||||||
oncontextmenu="return false"
|
oncontextmenu="return false"
|
||||||
class="rounded-md border border-gray-100 cursor-pointer"
|
class="rounded-md border border-gray-100 cursor-pointer"
|
||||||
ref="videoRef"
|
ref="videoRef"
|
||||||
>
|
:src="fileURL"
|
||||||
<source :src="fileURL" :type="type" />
|
:type="type"
|
||||||
</video>
|
></video>
|
||||||
<div
|
<div
|
||||||
v-if="!playing"
|
v-if="!playing"
|
||||||
class="absolute inset-0 flex items-center justify-center cursor-pointer"
|
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 { Pause, Maximize, Volume2, VolumeX } from 'lucide-vue-next'
|
||||||
import { Button, Dialog } from 'frappe-ui'
|
import { Button, Dialog } from 'frappe-ui'
|
||||||
import { formatSeconds, formatTimestamp } from '@/utils'
|
import { formatSeconds, formatTimestamp } from '@/utils'
|
||||||
|
import { useSettings } from '@/stores/settings'
|
||||||
import Play from '@/components/Icons/Play.vue'
|
import Play from '@/components/Icons/Play.vue'
|
||||||
import QuizInVideo from '@/components/Modals/QuizInVideo.vue'
|
import QuizInVideo from '@/components/Modals/QuizInVideo.vue'
|
||||||
|
|
||||||
@@ -170,6 +171,7 @@ const showQuizLoader = ref(false)
|
|||||||
const quizLoadTimer = ref(0)
|
const quizLoadTimer = ref(0)
|
||||||
const currentQuiz = ref(null)
|
const currentQuiz = ref(null)
|
||||||
const nextQuiz = ref({})
|
const nextQuiz = ref({})
|
||||||
|
const { preventSkippingVideos } = useSettings()
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
file: {
|
file: {
|
||||||
@@ -181,7 +183,7 @@ const props = defineProps({
|
|||||||
default: 'video/mp4',
|
default: 'video/mp4',
|
||||||
},
|
},
|
||||||
readOnly: {
|
readOnly: {
|
||||||
type: String,
|
type: Boolean,
|
||||||
default: true,
|
default: true,
|
||||||
},
|
},
|
||||||
quizzes: {
|
quizzes: {
|
||||||
@@ -190,6 +192,7 @@ const props = defineProps({
|
|||||||
},
|
},
|
||||||
saveQuizzes: {
|
saveQuizzes: {
|
||||||
type: Function,
|
type: Function,
|
||||||
|
default: () => {},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -295,6 +298,11 @@ const toggleMute = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const changeCurrentTime = () => {
|
const changeCurrentTime = () => {
|
||||||
|
if (
|
||||||
|
preventSkippingVideos.data &&
|
||||||
|
currentTime.value > videoRef.value.currentTime
|
||||||
|
)
|
||||||
|
return
|
||||||
videoRef.value.currentTime = currentTime.value
|
videoRef.value.currentTime = currentTime.value
|
||||||
updateNextQuiz()
|
updateNextQuiz()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,12 @@
|
|||||||
</template>
|
</template>
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
<Button v-if="canSeeStats()" @click="showVideoStats()">
|
||||||
|
<template #prefix>
|
||||||
|
<TrendingUp class="size-4 stroke-1.5" />
|
||||||
|
</template>
|
||||||
|
{{ __('Video Statistics') }}
|
||||||
|
</Button>
|
||||||
<CertificationLinks :courseName="courseName" />
|
<CertificationLinks :courseName="courseName" />
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -100,26 +106,15 @@
|
|||||||
<MessageCircleQuestion class="w-4 h-4 stroke-1.5" />
|
<MessageCircleQuestion class="w-4 h-4 stroke-1.5" />
|
||||||
</template>
|
</template>
|
||||||
</Button>
|
</Button>
|
||||||
<router-link
|
<Button v-if="lesson.data.prev" @click="switchLesson('prev')">
|
||||||
v-if="lesson.data.prev"
|
<template #prefix>
|
||||||
:to="{
|
<ChevronLeft class="w-4 h-4 stroke-1" />
|
||||||
name: 'Lesson',
|
</template>
|
||||||
params: {
|
<span>
|
||||||
courseName: courseName,
|
{{ __('Previous') }}
|
||||||
chapterNumber: lesson.data.prev.split('.')[0],
|
</span>
|
||||||
lessonNumber: lesson.data.prev.split('.')[1],
|
</Button>
|
||||||
},
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<Button>
|
|
||||||
<template #prefix>
|
|
||||||
<ChevronLeft class="w-4 h-4 stroke-1" />
|
|
||||||
</template>
|
|
||||||
<span>
|
|
||||||
{{ __('Previous') }}
|
|
||||||
</span>
|
|
||||||
</Button>
|
|
||||||
</router-link>
|
|
||||||
<router-link
|
<router-link
|
||||||
v-if="allowEdit()"
|
v-if="allowEdit()"
|
||||||
:to="{
|
:to="{
|
||||||
@@ -135,26 +130,16 @@
|
|||||||
{{ __('Edit') }}
|
{{ __('Edit') }}
|
||||||
</Button>
|
</Button>
|
||||||
</router-link>
|
</router-link>
|
||||||
<router-link
|
|
||||||
v-if="lesson.data.next"
|
<Button v-if="lesson.data.next" @click="switchLesson('next')">
|
||||||
:to="{
|
<template #suffix>
|
||||||
name: 'Lesson',
|
<ChevronRight class="w-4 h-4 stroke-1" />
|
||||||
params: {
|
</template>
|
||||||
courseName: courseName,
|
<span>
|
||||||
chapterNumber: lesson.data.next.split('.')[0],
|
{{ __('Next') }}
|
||||||
lessonNumber: lesson.data.next.split('.')[1],
|
</span>
|
||||||
},
|
</Button>
|
||||||
}"
|
|
||||||
>
|
|
||||||
<Button>
|
|
||||||
<template #suffix>
|
|
||||||
<ChevronRight class="w-4 h-4 stroke-1" />
|
|
||||||
</template>
|
|
||||||
<span>
|
|
||||||
{{ __('Next') }}
|
|
||||||
</span>
|
|
||||||
</Button>
|
|
||||||
</router-link>
|
|
||||||
<router-link
|
<router-link
|
||||||
v-else
|
v-else
|
||||||
:to="{
|
:to="{
|
||||||
@@ -262,13 +247,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<VideoStatistics
|
||||||
|
v-model="showStatsDialog"
|
||||||
|
:lessonName="lesson.data?.name"
|
||||||
|
:lessonTitle="lesson.data?.title"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import {
|
import {
|
||||||
createResource,
|
|
||||||
Badge,
|
Badge,
|
||||||
Breadcrumbs,
|
Breadcrumbs,
|
||||||
Button,
|
Button,
|
||||||
|
call,
|
||||||
|
createResource,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
usePageMeta,
|
usePageMeta,
|
||||||
} from 'frappe-ui'
|
} from 'frappe-ui'
|
||||||
@@ -292,6 +283,7 @@ import {
|
|||||||
Focus,
|
Focus,
|
||||||
Info,
|
Info,
|
||||||
MessageCircleQuestion,
|
MessageCircleQuestion,
|
||||||
|
TrendingUp,
|
||||||
} from 'lucide-vue-next'
|
} from 'lucide-vue-next'
|
||||||
import Discussions from '@/components/Discussions.vue'
|
import Discussions from '@/components/Discussions.vue'
|
||||||
import { getEditorTools, enablePlyr } from '@/utils'
|
import { getEditorTools, enablePlyr } from '@/utils'
|
||||||
@@ -302,6 +294,7 @@ import LessonContent from '@/components/LessonContent.vue'
|
|||||||
import CourseInstructors from '@/components/CourseInstructors.vue'
|
import CourseInstructors from '@/components/CourseInstructors.vue'
|
||||||
import ProgressBar from '@/components/ProgressBar.vue'
|
import ProgressBar from '@/components/ProgressBar.vue'
|
||||||
import CertificationLinks from '@/components/CertificationLinks.vue'
|
import CertificationLinks from '@/components/CertificationLinks.vue'
|
||||||
|
import VideoStatistics from '@/components/Modals/VideoStatistics.vue'
|
||||||
|
|
||||||
const user = inject('$user')
|
const user = inject('$user')
|
||||||
const socket = inject('$socket')
|
const socket = inject('$socket')
|
||||||
@@ -313,11 +306,13 @@ const instructorEditor = ref(null)
|
|||||||
const lessonProgress = ref(0)
|
const lessonProgress = ref(0)
|
||||||
const lessonContainer = ref(null)
|
const lessonContainer = ref(null)
|
||||||
const zenModeEnabled = ref(false)
|
const zenModeEnabled = ref(false)
|
||||||
|
const showStatsDialog = ref(false)
|
||||||
const hasQuiz = ref(false)
|
const hasQuiz = ref(false)
|
||||||
const discussionsContainer = ref(null)
|
const discussionsContainer = ref(null)
|
||||||
const timer = ref(0)
|
const timer = ref(0)
|
||||||
const { brand } = sessionStore()
|
const { brand } = sessionStore()
|
||||||
const sidebarStore = useSidebar()
|
const sidebarStore = useSidebar()
|
||||||
|
const plyrSources = ref([])
|
||||||
let timerInterval
|
let timerInterval
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -337,7 +332,6 @@ const props = defineProps({
|
|||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
startTimer()
|
startTimer()
|
||||||
console.log(sidebarStore.isSidebarCollapsed)
|
|
||||||
sidebarStore.isSidebarCollapsed = true
|
sidebarStore.isSidebarCollapsed = true
|
||||||
document.addEventListener('fullscreenchange', attachFullscreenEvent)
|
document.addEventListener('fullscreenchange', attachFullscreenEvent)
|
||||||
socket.on('update_lesson_progress', (data) => {
|
socket.on('update_lesson_progress', (data) => {
|
||||||
@@ -362,6 +356,7 @@ const attachFullscreenEvent = () => {
|
|||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
document.removeEventListener('fullscreenchange', attachFullscreenEvent)
|
document.removeEventListener('fullscreenchange', attachFullscreenEvent)
|
||||||
sidebarStore.isSidebarCollapsed = false
|
sidebarStore.isSidebarCollapsed = false
|
||||||
|
trackVideoWatchDuration()
|
||||||
})
|
})
|
||||||
|
|
||||||
const lesson = createResource({
|
const lesson = createResource({
|
||||||
@@ -457,36 +452,157 @@ const breadcrumbs = computed(() => {
|
|||||||
return items
|
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(
|
watch(
|
||||||
[() => route.params.chapterNumber, () => route.params.lessonNumber],
|
[() => route.params.chapterNumber, () => route.params.lessonNumber],
|
||||||
(
|
async (
|
||||||
[newChapterNumber, newLessonNumber],
|
[newChapterNumber, newLessonNumber],
|
||||||
[oldChapterNumber, oldLessonNumber]
|
[oldChapterNumber, oldLessonNumber]
|
||||||
) => {
|
) => {
|
||||||
if (newChapterNumber || newLessonNumber) {
|
if (newChapterNumber || newLessonNumber) {
|
||||||
editor.value = null
|
plyrSources.value = []
|
||||||
instructorEditor.value = null
|
await nextTick()
|
||||||
allowDiscussions.value = false
|
resetLessonState(newChapterNumber, newLessonNumber)
|
||||||
lesson.submit({
|
|
||||||
chapter: newChapterNumber,
|
|
||||||
lesson: newLessonNumber,
|
|
||||||
})
|
|
||||||
clearInterval(timerInterval)
|
|
||||||
timer.value = 0
|
|
||||||
startTimer()
|
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(
|
watch(
|
||||||
() => lesson.data,
|
() => lesson.data,
|
||||||
(data) => {
|
async (data) => {
|
||||||
setupLesson(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 = () => {
|
const startTimer = () => {
|
||||||
timerInterval = setInterval(() => {
|
timerInterval = setInterval(() => {
|
||||||
timer.value++
|
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 = () => {
|
const canGoZen = () => {
|
||||||
if (
|
if (
|
||||||
user.data?.is_moderator ||
|
user.data?.is_moderator ||
|
||||||
|
|||||||
@@ -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({
|
const livecodeURL = createResource({
|
||||||
url: 'frappe.client.get_single_value',
|
url: 'frappe.client.get_single_value',
|
||||||
params: {
|
params: {
|
||||||
@@ -77,7 +71,6 @@ export const sessionStore = defineStore('lms-session', () => {
|
|||||||
logout,
|
logout,
|
||||||
brand,
|
brand,
|
||||||
branding,
|
branding,
|
||||||
sidebarSettings,
|
|
||||||
livecodeURL,
|
livecodeURL,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -9,21 +9,38 @@ export const useSettings = defineStore('settings', () => {
|
|||||||
const activeTab = ref(null)
|
const activeTab = ref(null)
|
||||||
|
|
||||||
const learningPaths = createResource({
|
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,
|
auto: true,
|
||||||
cache: ['learningPath'],
|
cache: ['learningPath'],
|
||||||
})
|
})
|
||||||
|
|
||||||
const allowGuestAccess = createResource({
|
const allowGuestAccess = createResource({
|
||||||
url: 'lms.lms.api.is_guest_allowed',
|
url: 'lms.lms.api.get_lms_setting',
|
||||||
|
params: { field: 'allow_guest_access' },
|
||||||
auto: true,
|
auto: true,
|
||||||
cache: ['allowGuestAccess'],
|
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 {
|
return {
|
||||||
isSettingsOpen,
|
isSettingsOpen,
|
||||||
activeTab,
|
activeTab,
|
||||||
learningPaths,
|
learningPaths,
|
||||||
allowGuestAccess,
|
allowGuestAccess,
|
||||||
|
preventSkippingVideos,
|
||||||
|
sidebarSettings,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { watch } from 'vue'
|
|
||||||
import { call, toast } from 'frappe-ui'
|
import { call, toast } from 'frappe-ui'
|
||||||
import { useTimeAgo } from '@vueuse/core'
|
import { useTimeAgo } from '@vueuse/core'
|
||||||
import { Quiz } from '@/utils/quiz'
|
import { Quiz } from '@/utils/quiz'
|
||||||
@@ -531,33 +530,85 @@ export const canCreateCourse = () => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const enablePlyr = () => {
|
export const enablePlyr = async () => {
|
||||||
setTimeout(() => {
|
await wait(500)
|
||||||
const videoElement = document.getElementsByClassName('video-player')
|
|
||||||
if (videoElement.length === 0) return
|
|
||||||
|
|
||||||
Array.from(videoElement).forEach((video) => {
|
const players = []
|
||||||
const src = video.getAttribute('src')
|
const videoElements = document.getElementsByClassName('video-player')
|
||||||
if (src) {
|
|
||||||
let videoID = src.split('/').pop()
|
if (videoElements.length === 0) return players
|
||||||
video.setAttribute('data-plyr-embed-id', videoID)
|
|
||||||
}
|
Array.from(videoElements).forEach((video) => {
|
||||||
new Plyr(video, {
|
setupPlyrForVideo(video, players)
|
||||||
youtube: {
|
|
||||||
noCookie: true,
|
|
||||||
},
|
|
||||||
controls: [
|
|
||||||
'play-large',
|
|
||||||
'play',
|
|
||||||
'progress',
|
|
||||||
'current-time',
|
|
||||||
'mute',
|
|
||||||
'volume',
|
|
||||||
'fullscreen',
|
|
||||||
],
|
|
||||||
})
|
|
||||||
}, 500)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
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) => {
|
export const openSettings = (category, close = null) => {
|
||||||
@@ -567,7 +618,6 @@ export const openSettings = (category, close = null) => {
|
|||||||
}
|
}
|
||||||
settingsStore.activeTab = category
|
settingsStore.activeTab = category
|
||||||
settingsStore.isSettingsOpen = true
|
settingsStore.isSettingsOpen = true
|
||||||
console.log(settingsStore.activeTab, settingsStore.isSettingsOpen)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const cleanError = (message) => {
|
export const cleanError = (message) => {
|
||||||
|
|||||||
@@ -553,6 +553,7 @@ def get_sidebar_settings():
|
|||||||
"jobs",
|
"jobs",
|
||||||
"statistics",
|
"statistics",
|
||||||
"notifications",
|
"notifications",
|
||||||
|
"programming_exercises",
|
||||||
]
|
]
|
||||||
for item in items:
|
for item in items:
|
||||||
sidebar_items[item] = lms_settings.get(item)
|
sidebar_items[item] = lms_settings.get(item)
|
||||||
@@ -1304,13 +1305,8 @@ def get_notifications(filters):
|
|||||||
|
|
||||||
|
|
||||||
@frappe.whitelist(allow_guest=True)
|
@frappe.whitelist(allow_guest=True)
|
||||||
def is_guest_allowed():
|
def get_lms_setting(field):
|
||||||
return frappe.get_cached_value("LMS Settings", None, "allow_guest_access")
|
return frappe.get_cached_value("LMS Settings", None, field)
|
||||||
|
|
||||||
|
|
||||||
@frappe.whitelist(allow_guest=True)
|
|
||||||
def is_learning_path_enabled():
|
|
||||||
return frappe.get_cached_value("LMS Settings", None, "enable_learning_paths")
|
|
||||||
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
@frappe.whitelist()
|
||||||
@@ -1559,3 +1555,40 @@ def update_test_cases(test_cases, submission):
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
test_case.insert()
|
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()
|
||||||
|
|||||||
@@ -12,6 +12,8 @@
|
|||||||
"column_break_zdel",
|
"column_break_zdel",
|
||||||
"allow_guest_access",
|
"allow_guest_access",
|
||||||
"enable_learning_paths",
|
"enable_learning_paths",
|
||||||
|
"prevent_skipping_videos",
|
||||||
|
"column_break_bjis",
|
||||||
"unsplash_access_key",
|
"unsplash_access_key",
|
||||||
"livecode_url",
|
"livecode_url",
|
||||||
"section_break_szgq",
|
"section_break_szgq",
|
||||||
@@ -36,6 +38,7 @@
|
|||||||
"batches",
|
"batches",
|
||||||
"certified_participants",
|
"certified_participants",
|
||||||
"certified_members",
|
"certified_members",
|
||||||
|
"programming_exercises",
|
||||||
"column_break_exdz",
|
"column_break_exdz",
|
||||||
"jobs",
|
"jobs",
|
||||||
"statistics",
|
"statistics",
|
||||||
@@ -72,7 +75,6 @@
|
|||||||
"default": "https://livecode.dev.fossunited.org",
|
"default": "https://livecode.dev.fossunited.org",
|
||||||
"fieldname": "livecode_url",
|
"fieldname": "livecode_url",
|
||||||
"fieldtype": "Data",
|
"fieldtype": "Data",
|
||||||
"hidden": 1,
|
|
||||||
"label": "LiveCode URL"
|
"label": "LiveCode URL"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -405,13 +407,29 @@
|
|||||||
"fieldname": "certified_members",
|
"fieldname": "certified_members",
|
||||||
"fieldtype": "Check",
|
"fieldtype": "Check",
|
||||||
"label": "Certified Members"
|
"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",
|
||||||
|
"fieldtype": "Check",
|
||||||
|
"label": "Programming Exercises"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"grid_page_length": 50,
|
"grid_page_length": 50,
|
||||||
"index_web_pages_for_search": 1,
|
"index_web_pages_for_search": 1,
|
||||||
"issingle": 1,
|
"issingle": 1,
|
||||||
"links": [],
|
"links": [],
|
||||||
"modified": "2025-05-30 19:02:51.381668",
|
"modified": "2025-07-01 17:01:58.466698",
|
||||||
"modified_by": "sayali@frappe.io",
|
"modified_by": "sayali@frappe.io",
|
||||||
"module": "LMS",
|
"module": "LMS",
|
||||||
"name": "LMS Settings",
|
"name": "LMS Settings",
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|
||||||
|
// },
|
||||||
|
// });
|
||||||
@@ -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": []
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -978,7 +978,7 @@ def change_currency(amount, currency, country=None):
|
|||||||
|
|
||||||
|
|
||||||
@frappe.whitelist(allow_guest=True)
|
@frappe.whitelist(allow_guest=True)
|
||||||
def get_courses(filters=None, start=0, page_length=20):
|
def get_courses(filters=None, start=0):
|
||||||
"""Returns the list of courses."""
|
"""Returns the list of courses."""
|
||||||
|
|
||||||
if not filters:
|
if not filters:
|
||||||
@@ -994,8 +994,8 @@ def get_courses(filters=None, start=0, page_length=20):
|
|||||||
or_filters=or_filters,
|
or_filters=or_filters,
|
||||||
order_by="enrollments desc",
|
order_by="enrollments desc",
|
||||||
start=start,
|
start=start,
|
||||||
page_length=page_length,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if show_featured:
|
if show_featured:
|
||||||
courses = get_featured_courses(filters, or_filters, fields) + courses
|
courses = get_featured_courses(filters, or_filters, fields) + courses
|
||||||
|
|
||||||
@@ -1320,13 +1320,23 @@ def get_lesson(course, chapter, lesson):
|
|||||||
lesson_details.progress = progress
|
lesson_details.progress = progress
|
||||||
lesson_details.prev = neighbours["prev"]
|
lesson_details.prev = neighbours["prev"]
|
||||||
lesson_details.membership = membership
|
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.instructors = get_instructors("LMS Course", course)
|
||||||
lesson_details.course_title = course_info.title
|
lesson_details.course_title = course_info.title
|
||||||
lesson_details.paid_certificate = course_info.paid_certificate
|
lesson_details.paid_certificate = course_info.paid_certificate
|
||||||
lesson_details.disable_self_learning = course_info.disable_self_learning
|
lesson_details.disable_self_learning = course_info.disable_self_learning
|
||||||
|
lesson_details.videos = get_video_details(lesson_name)
|
||||||
return lesson_details
|
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):
|
def get_neighbour_lesson(course, chapter, lesson):
|
||||||
numbers = []
|
numbers = []
|
||||||
current = f"{chapter}.{lesson}"
|
current = f"{chapter}.{lesson}"
|
||||||
@@ -2086,7 +2096,7 @@ def enroll_in_program_course(program, course):
|
|||||||
|
|
||||||
|
|
||||||
@frappe.whitelist(allow_guest=True)
|
@frappe.whitelist(allow_guest=True)
|
||||||
def get_batches(filters=None, start=0, page_length=20, order_by="start_date"):
|
def get_batches(filters=None, start=0, order_by="start_date"):
|
||||||
if not filters:
|
if not filters:
|
||||||
filters = {}
|
filters = {}
|
||||||
|
|
||||||
@@ -2119,7 +2129,6 @@ def get_batches(filters=None, start=0, page_length=20, order_by="start_date"):
|
|||||||
],
|
],
|
||||||
order_by=order_by,
|
order_by=order_by,
|
||||||
start=start,
|
start=start,
|
||||||
page_length=page_length,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
batches = filter_batches_based_on_start_time(batches, filters)
|
batches = filter_batches_based_on_start_time(batches, filters)
|
||||||
|
|||||||
@@ -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_live_class
|
||||||
lms.patches.v2_0.link_zoom_account_to_batch
|
lms.patches.v2_0.link_zoom_account_to_batch
|
||||||
lms.patches.v2_0.sidebar_for_certified_members
|
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
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import frappe
|
||||||
|
|
||||||
|
|
||||||
|
def execute():
|
||||||
|
frappe.db.set_single_value("LMS Settings", "programming_exercises", True)
|
||||||
Reference in New Issue
Block a user