mirror of
https://github.com/frappe/lms.git
synced 2026-04-19 22:52:29 +03:00
Merge branch 'frappe:develop' into fix/persona
This commit is contained in:
2
frontend/components.d.ts
vendored
2
frontend/components.d.ts
vendored
@@ -60,7 +60,7 @@ declare module 'vue' {
|
||||
ExplanationVideos: typeof import('./src/components/Modals/ExplanationVideos.vue')['default']
|
||||
FeedbackModal: typeof import('./src/components/Modals/FeedbackModal.vue')['default']
|
||||
FrappeCloudIcon: typeof import('./src/components/Icons/FrappeCloudIcon.vue')['default']
|
||||
GoogleMeetAccountModal: typeof import('./src/components/Modals/GoogleMeetAccountModal.vue')['default']
|
||||
GoogleMeetAccountModal: typeof import('./src/components/Settings/GoogleMeetAccountModal.vue')['default']
|
||||
GoogleMeetSettings: typeof import('./src/components/Settings/GoogleMeetSettings.vue')['default']
|
||||
IconPicker: typeof import('./src/components/Controls/IconPicker.vue')['default']
|
||||
IndicatorIcon: typeof import('./src/components/Icons/IndicatorIcon.vue')['default']
|
||||
|
||||
@@ -3,13 +3,13 @@
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center text-sm font-medium space-x-2">
|
||||
<span>
|
||||
{{ __('What does include in preview mean?') }}
|
||||
{{ __('What are Instructor Notes?') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-xs text-ink-gray-5 mb-1 leading-5">
|
||||
{{
|
||||
__(
|
||||
'If Include in Preview is enabled for a lesson then the lesson will also be accessible to non logged in users.'
|
||||
'Instructor Notes are private notes that only instructors can see. They can be used to provide additional context or guidance for the lesson.'
|
||||
)
|
||||
}}
|
||||
</div>
|
||||
|
||||
@@ -16,7 +16,12 @@
|
||||
>
|
||||
<template #body-content>
|
||||
<div class="space-y-4 text-base">
|
||||
<FormControl label="Title" v-model="chapter.title" :required="true" />
|
||||
<FormControl
|
||||
label="Title"
|
||||
v-model="chapter.title"
|
||||
:required="true"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<Switch
|
||||
size="sm"
|
||||
:label="__('SCORM Package')"
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
<template>
|
||||
<div class="flex flex-col h-full">
|
||||
<div class="flex flex-col h-full text-p-base">
|
||||
<div>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="font-semibold mb-1 text-ink-gray-9">
|
||||
{{ __(label) }}
|
||||
<div class="space-y-2">
|
||||
<div class="font-semibold text-xl text-ink-gray-9">
|
||||
{{ __(label) }}
|
||||
</div>
|
||||
<div class="text-ink-gray-6 leading-5">
|
||||
{{ __(description) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-x-2">
|
||||
<Badge
|
||||
@@ -21,9 +26,6 @@
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-xs text-ink-gray-5">
|
||||
{{ __(description) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="overflow-y-auto">
|
||||
<SettingFields :sections="sections" :data="branding.data" />
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div class="flex min-h-0 flex-col text-base">
|
||||
<div class="flex items-center justify-between mb-5">
|
||||
<div>
|
||||
<div class="text-xl font-semibold mb-1 text-ink-gray-9">
|
||||
<div class="text-xl font-semibold mb-2 text-ink-gray-9">
|
||||
{{ __(label) }}
|
||||
</div>
|
||||
<div class="text-ink-gray-6 leading-5">
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div class="flex min-h-0 flex-col text-base">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="text-xl font-semibold mb-1 text-ink-gray-9">
|
||||
<div class="text-xl font-semibold mb-2 text-ink-gray-9">
|
||||
{{ __(label) }}
|
||||
</div>
|
||||
<div class="text-ink-gray-6 leading-5">
|
||||
@@ -10,7 +10,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex item-center space-x-2">
|
||||
<Button variant="solid" @click="() => (showForm = !showForm)">
|
||||
<Button @click="() => (showForm = !showForm)">
|
||||
<template #prefix>
|
||||
<Plus class="size-4 stroke-1.5" />
|
||||
</template>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div class="flex min-h-0 flex-col text-base">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="text-xl font-semibold mb-1 text-ink-gray-9">
|
||||
<div class="text-xl font-semibold mb-2 text-ink-gray-9">
|
||||
{{ __(label) }}
|
||||
</div>
|
||||
<div class="text-ink-gray-6 leading-5">
|
||||
@@ -10,7 +10,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex item-center space-x-2">
|
||||
<Button variant="solid" @click="() => (showForm = !showForm)">
|
||||
<Button @click="() => (showForm = !showForm)">
|
||||
<template #prefix>
|
||||
<Plus class="size-4 stroke-1.5" />
|
||||
</template>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div class="flex min-h-0 flex-col text-base">
|
||||
<div class="flex items-center justify-between mb-5">
|
||||
<div>
|
||||
<div class="text-xl font-semibold mb-1 text-ink-gray-9">
|
||||
<div class="text-xl font-semibold mb-2 text-ink-gray-9">
|
||||
{{ __(label) }}
|
||||
</div>
|
||||
<div class="text-ink-gray-6 leading-5">
|
||||
|
||||
@@ -2,10 +2,13 @@
|
||||
<div class="flex flex-col h-full text-base overflow-y-hidden">
|
||||
<div class="">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="flex items-center space-x-2">
|
||||
<div class="flex flex-col space-y-2">
|
||||
<div class="text-xl font-semibold leading-none text-ink-gray-9">
|
||||
{{ __(label) }}
|
||||
</div>
|
||||
<div class="text-ink-gray-6 leading-5">
|
||||
{{ __(description) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-x-2">
|
||||
<Badge
|
||||
@@ -19,9 +22,6 @@
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-ink-gray-6 leading-5">
|
||||
{{ __(description) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SettingFields :sections="sections" :data="data.doc" />
|
||||
|
||||
@@ -269,41 +269,6 @@ const tabsStructure = computed(() => {
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Lists',
|
||||
hideLabel: false,
|
||||
items: [
|
||||
{
|
||||
label: 'Members',
|
||||
description:
|
||||
'Add new members or manage roles and permissions of existing members',
|
||||
icon: 'UserRoundPlus',
|
||||
template: markRaw(Members),
|
||||
},
|
||||
{
|
||||
label: 'Evaluators',
|
||||
description: '',
|
||||
icon: 'UserCheck',
|
||||
description:
|
||||
'Add new evaluators or check the slots existing evaluators',
|
||||
template: markRaw(Evaluators),
|
||||
},
|
||||
{
|
||||
label: 'Zoom Accounts',
|
||||
description:
|
||||
'Manage zoom accounts to conduct live classes from batches',
|
||||
icon: 'Video',
|
||||
template: markRaw(ZoomSettings),
|
||||
},
|
||||
{
|
||||
label: 'Google Meet Accounts',
|
||||
description:
|
||||
'Manage Google Meet accounts to conduct live classes from batches',
|
||||
icon: 'Video',
|
||||
template: markRaw(GoogleMeetSettings),
|
||||
},
|
||||
{
|
||||
label: 'Badges',
|
||||
description:
|
||||
@@ -325,6 +290,27 @@ const tabsStructure = computed(() => {
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Users',
|
||||
hideLabel: false,
|
||||
items: [
|
||||
{
|
||||
label: 'Members',
|
||||
description:
|
||||
'Add new members or manage roles and permissions of existing members',
|
||||
icon: 'User',
|
||||
template: markRaw(Members),
|
||||
},
|
||||
{
|
||||
label: 'Evaluators',
|
||||
description: '',
|
||||
icon: 'UserCircle2',
|
||||
description:
|
||||
'Add new evaluators or check the slots of existing evaluators',
|
||||
template: markRaw(Evaluators),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Payment',
|
||||
hideLabel: false,
|
||||
@@ -395,6 +381,26 @@ const tabsStructure = computed(() => {
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Conferencing',
|
||||
hideLabel: false,
|
||||
items: [
|
||||
{
|
||||
label: 'Zoom',
|
||||
description:
|
||||
'Manage zoom accounts to conduct live classes from batches',
|
||||
icon: 'Video',
|
||||
template: markRaw(ZoomSettings),
|
||||
},
|
||||
{
|
||||
label: 'Google Meet',
|
||||
description:
|
||||
'Manage Google Meet accounts to conduct live classes from batches',
|
||||
icon: 'Presentation',
|
||||
template: markRaw(GoogleMeetSettings),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Customize',
|
||||
hideLabel: false,
|
||||
@@ -402,6 +408,8 @@ const tabsStructure = computed(() => {
|
||||
{
|
||||
label: 'Branding',
|
||||
icon: 'Blocks',
|
||||
description:
|
||||
'Customize the brand name and logo to make the application your own',
|
||||
template: markRaw(BrandSettings),
|
||||
sections: [
|
||||
{
|
||||
@@ -490,6 +498,8 @@ const tabsStructure = computed(() => {
|
||||
{
|
||||
label: 'Signup',
|
||||
icon: 'LogIn',
|
||||
description:
|
||||
'Manage the settings related to user signup and registration',
|
||||
sections: [
|
||||
{
|
||||
columns: [
|
||||
@@ -525,6 +535,8 @@ const tabsStructure = computed(() => {
|
||||
{
|
||||
label: 'SEO',
|
||||
icon: 'Search',
|
||||
description:
|
||||
'Manage the SEO settings to improve your website ranking on search engines',
|
||||
sections: [
|
||||
{
|
||||
columns: [
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div class="flex min-h-0 flex-col text-base">
|
||||
<div class="flex items-center justify-between mb-5">
|
||||
<div>
|
||||
<div class="text-xl font-semibold mb-1 text-ink-gray-9">
|
||||
<div class="text-xl font-semibold mb-2 text-ink-gray-9">
|
||||
{{ __(label) }}
|
||||
</div>
|
||||
<div class="text-ink-gray-6 leading-5">
|
||||
|
||||
@@ -507,11 +507,11 @@ const conferencingOptions = computed(() => {
|
||||
const mediumOptions = computed(() => {
|
||||
return [
|
||||
{
|
||||
label: 'Online',
|
||||
label: __('Online'),
|
||||
value: 'Online',
|
||||
},
|
||||
{
|
||||
label: 'Offline',
|
||||
label: __('Offline'),
|
||||
value: 'Offline',
|
||||
},
|
||||
]
|
||||
|
||||
@@ -8,81 +8,86 @@
|
||||
>
|
||||
<template #body-content>
|
||||
<div class="text-base">
|
||||
<div class="grid grid-cols-2 gap-10">
|
||||
<div class="space-y-5">
|
||||
<FormControl
|
||||
v-model="batch.title"
|
||||
:label="__('Title')"
|
||||
:required="true"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="batch.start_date"
|
||||
:label="__('Start Date')"
|
||||
type="date"
|
||||
:required="true"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="batch.end_date"
|
||||
:label="__('End Date')"
|
||||
type="date"
|
||||
:required="true"
|
||||
/>
|
||||
<Link
|
||||
doctype="LMS Category"
|
||||
v-model="batch.category"
|
||||
:label="__('Category')"
|
||||
:allowCreate="true"
|
||||
:onCreate="
|
||||
() => {
|
||||
openSettings('Categories')
|
||||
show = false
|
||||
}
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-5">
|
||||
<FormControl
|
||||
v-model="batch.start_time"
|
||||
:label="__('Start Time')"
|
||||
type="time"
|
||||
:required="true"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="batch.end_time"
|
||||
:label="__('End Time')"
|
||||
type="time"
|
||||
:required="true"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="batch.timezone"
|
||||
:label="__('Timezone')"
|
||||
:required="true"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="batch.seat_count"
|
||||
:label="__('Seat Count')"
|
||||
type="number"
|
||||
:required="false"
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-5">
|
||||
<FormControl
|
||||
v-model="batch.title"
|
||||
:label="__('Title')"
|
||||
:required="true"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="batch.start_date"
|
||||
:label="__('Start Date')"
|
||||
type="date"
|
||||
:required="true"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="batch.end_date"
|
||||
:label="__('End Date')"
|
||||
type="date"
|
||||
:required="true"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="batch.start_time"
|
||||
:label="__('Start Time')"
|
||||
type="time"
|
||||
:required="true"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="batch.end_time"
|
||||
:label="__('End Time')"
|
||||
type="time"
|
||||
:required="true"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="batch.timezone"
|
||||
:label="__('Timezone')"
|
||||
:required="true"
|
||||
/>
|
||||
<Link
|
||||
doctype="LMS Category"
|
||||
v-model="batch.category"
|
||||
:label="__('Category')"
|
||||
:allowCreate="true"
|
||||
:onCreate="
|
||||
() => {
|
||||
openSettings('Categories')
|
||||
show = false
|
||||
}
|
||||
"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="batch.seat_count"
|
||||
:label="__('Seat Count')"
|
||||
type="number"
|
||||
:required="false"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="batch.medium"
|
||||
type="select"
|
||||
:options="mediumOptions"
|
||||
:label="__('Medium')"
|
||||
class="mb-4"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-5 border-t mt-5 pt-5">
|
||||
<MultiSelect
|
||||
v-model="batch.instructors"
|
||||
doctype="Course Evaluator"
|
||||
:label="__('Instructors')"
|
||||
:required="true"
|
||||
:onCreate="(close: () => void) => openSettings('Evaluators', close)"
|
||||
:filters="{ ignore_user_type: 1 }"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="batch.description"
|
||||
:label="__('Description')"
|
||||
type="textarea"
|
||||
:required="true"
|
||||
:rows="4"
|
||||
/>
|
||||
<div class="grid grid-cols-2 gap-5">
|
||||
<MultiSelect
|
||||
v-model="batch.instructors"
|
||||
doctype="Course Evaluator"
|
||||
:label="__('Instructors')"
|
||||
:required="true"
|
||||
:onCreate="(close: () => void) => openSettings('Evaluators', close)"
|
||||
:filters="{ ignore_user_type: 1 }"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="batch.description"
|
||||
:label="__('Description')"
|
||||
type="textarea"
|
||||
:required="true"
|
||||
:rows="4"
|
||||
/>
|
||||
</div>
|
||||
<div class="">
|
||||
<div class="mb-1.5 text-sm text-ink-gray-5">
|
||||
{{ __('Batch Details') }}
|
||||
@@ -93,7 +98,7 @@
|
||||
@change="(val: string) => (batch.batch_details = val)"
|
||||
:editable="true"
|
||||
:fixedMenu="true"
|
||||
editorClass="prose-sm max-w-none border-b border-x bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[10rem]"
|
||||
editorClass="prose-sm max-w-none border-b border-x bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[10rem] max-h-[14rem] overflow-auto"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -111,7 +116,7 @@
|
||||
<script setup lang="ts">
|
||||
import { Button, Dialog, FormControl, TextEditor, toast } from 'frappe-ui'
|
||||
import { useOnboarding, useTelemetry } from 'frappe-ui/frappe'
|
||||
import { ref, inject, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { computed, inject, onMounted, onBeforeUnmount, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { cleanError, openSettings, sanitizeHTML, escapeHTML } from '@/utils'
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
@@ -139,6 +144,7 @@ type Batch = {
|
||||
instructors: string[]
|
||||
category: string | null
|
||||
seat_count: number
|
||||
medium: string | null
|
||||
}
|
||||
|
||||
const batch = ref<Batch>({
|
||||
@@ -153,6 +159,7 @@ const batch = ref<Batch>({
|
||||
instructors: [],
|
||||
category: null,
|
||||
seat_count: 0,
|
||||
medium: null,
|
||||
})
|
||||
|
||||
const validateFields = () => {
|
||||
@@ -227,4 +234,17 @@ onBeforeUnmount(() => {
|
||||
data: batch.value,
|
||||
})
|
||||
})
|
||||
|
||||
const mediumOptions = computed(() => {
|
||||
return [
|
||||
{
|
||||
label: __('Online'),
|
||||
value: 'Online',
|
||||
},
|
||||
{
|
||||
label: __('Offline'),
|
||||
value: 'Offline',
|
||||
},
|
||||
]
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
v-model="course.title"
|
||||
:label="__('Title')"
|
||||
:required="true"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<Link
|
||||
doctype="LMS Category"
|
||||
@@ -57,7 +58,7 @@
|
||||
@change="(val: string) => (course.description = val)"
|
||||
:editable="true"
|
||||
:fixedMenu="true"
|
||||
editorClass="prose-sm max-w-none border-b border-x border-outline-gray-modals bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[10rem]"
|
||||
editorClass="prose-sm max-w-none border-b border-x border-outline-gray-modals bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[10rem] max-h-[17rem] overflow-auto"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -87,6 +88,7 @@ const router = useRouter()
|
||||
const { capture } = useTelemetry()
|
||||
const { updateOnboardingStep } = useOnboarding('learning')
|
||||
const user = inject<any>('$user')
|
||||
const courseCreated = ref(false)
|
||||
|
||||
const props = defineProps<{
|
||||
courses: any
|
||||
@@ -139,6 +141,7 @@ const saveCourse = (close: () => void = () => {}) => {
|
||||
toast.success(__('Course created successfully'))
|
||||
close()
|
||||
capture('course_created')
|
||||
courseCreated.value = true
|
||||
router.push({
|
||||
name: 'CourseDetail',
|
||||
params: { courseName: data.name },
|
||||
@@ -178,8 +181,10 @@ onMounted(() => {
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('keydown', keyboardShortcut)
|
||||
capture('course_form_closed', {
|
||||
data: course.value,
|
||||
})
|
||||
if (!courseCreated.value) {
|
||||
capture('course_form_closed', {
|
||||
data: course.value,
|
||||
})
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -15,17 +15,22 @@
|
||||
</Button>
|
||||
</header>
|
||||
<div class="py-5">
|
||||
<div class="w-5/6 mx-auto">
|
||||
<div class="grid grid-cols-2 gap-5 w-5/6 mx-auto">
|
||||
<FormControl
|
||||
v-model="lesson.title"
|
||||
label="Title"
|
||||
:label="__('Title')"
|
||||
class="mb-4"
|
||||
:required="true"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<FormControl
|
||||
<Switch
|
||||
v-model="lesson.include_in_preview"
|
||||
type="checkbox"
|
||||
label="Include in Preview"
|
||||
:label="__('Include in Preview')"
|
||||
:description="
|
||||
__(
|
||||
'If enabled, the lesson will also be accessible to users who are not enrolled in the course.'
|
||||
)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<div class="border-t mt-4">
|
||||
@@ -83,6 +88,7 @@ import {
|
||||
Button,
|
||||
createResource,
|
||||
FormControl,
|
||||
Switch,
|
||||
usePageMeta,
|
||||
toast,
|
||||
} from 'frappe-ui'
|
||||
@@ -708,8 +714,8 @@ iframe {
|
||||
height: 15px;
|
||||
}
|
||||
|
||||
.ce-popover--opened > .ce-popover__container {
|
||||
max-height: unset;
|
||||
.ce-popover--opened {
|
||||
max-height: unset !important;
|
||||
}
|
||||
|
||||
.cdx-search-field__icon svg {
|
||||
|
||||
@@ -126,6 +126,7 @@ export function getEditorTools() {
|
||||
defaultStyle: 'ordered',
|
||||
},
|
||||
},
|
||||
upload: Upload,
|
||||
table: {
|
||||
class: Table,
|
||||
inlineToolbar: true,
|
||||
@@ -133,7 +134,6 @@ export function getEditorTools() {
|
||||
quiz: Quiz,
|
||||
assignment: Assignment,
|
||||
program: Program,
|
||||
upload: Upload,
|
||||
markdown: {
|
||||
class: Markdown,
|
||||
inlineToolbar: true,
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "2.44.0"
|
||||
__version__ = "2.45.2"
|
||||
|
||||
@@ -199,8 +199,6 @@ def create_event(evaluation: dict):
|
||||
"subject": f"Evaluation of {evaluation.member_name}",
|
||||
"starts_on": f"{evaluation.date} {evaluation.start_time}",
|
||||
"ends_on": f"{evaluation.date} {evaluation.end_time}",
|
||||
"reference_doctype": "LMS Certificate Request",
|
||||
"reference_docname": evaluation.name,
|
||||
}
|
||||
)
|
||||
event.save()
|
||||
|
||||
@@ -14,10 +14,7 @@ from lms.lms.doctype.lms_batch.lms_batch import authenticate
|
||||
|
||||
class LMSLiveClass(Document):
|
||||
def after_insert(self):
|
||||
if self.conferencing_provider == "Google Meet":
|
||||
self._create_google_meet_event()
|
||||
else:
|
||||
self._create_calendar_event()
|
||||
self.create_calendar_event()
|
||||
|
||||
def on_update(self):
|
||||
if not self.event:
|
||||
@@ -33,13 +30,11 @@ class LMSLiveClass(Document):
|
||||
|
||||
self._update_linked_event()
|
||||
|
||||
def on_trash(self):
|
||||
if self.event and frappe.db.exists("Event", self.event):
|
||||
event_name = self.event
|
||||
frappe.db.set_value("LMS Live Class", self.name, "event", "")
|
||||
frappe.delete_doc("Event", event_name, ignore_permissions=True)
|
||||
def after_delete(self):
|
||||
if self.event:
|
||||
frappe.delete_doc("Event", self.event, force=True)
|
||||
|
||||
def _get_participants(self):
|
||||
def get_participants(self):
|
||||
participants = frappe.get_all("LMS Batch Enrollment", {"batch": self.batch_name}, pluck="member")
|
||||
instructors = frappe.get_all(
|
||||
"Course Instructor", {"parenttype": "LMS Batch", "parent": self.batch_name}, pluck="instructor"
|
||||
@@ -48,12 +43,12 @@ class LMSLiveClass(Document):
|
||||
participants.extend(instructors)
|
||||
return list(set(participants))
|
||||
|
||||
def _build_event_description(self):
|
||||
def build_event_description(self):
|
||||
description = f"A Live Class has been scheduled on {format_date(self.date, 'medium')} at {format_time(self.time, 'hh:mm a')}."
|
||||
if self.join_url:
|
||||
description += f" Click on this link to join. {self.join_url}."
|
||||
description += f" Click on this link to join. {self.join_url}. \n\n"
|
||||
if self.description:
|
||||
description += f" {self.description}"
|
||||
description += f"{self.description}"
|
||||
return description
|
||||
|
||||
def _update_linked_event(self):
|
||||
@@ -63,99 +58,41 @@ class LMSLiveClass(Document):
|
||||
event.subject = f"Live Class on {self.title}"
|
||||
event.starts_on = start
|
||||
event.ends_on = get_datetime(start) + timedelta(minutes=cint(self.duration))
|
||||
event.description = self._build_event_description()
|
||||
event.description = self.build_event_description()
|
||||
|
||||
event.save(ignore_permissions=True)
|
||||
|
||||
def _create_calendar_event(self):
|
||||
calendar = frappe.db.get_value("Google Calendar", {"user": frappe.session.user, "enable": 1}, "name")
|
||||
def create_calendar_event(self):
|
||||
if self.conferencing_provider == "Google Meet":
|
||||
calendar = frappe.db.get_value(
|
||||
"LMS Google Meet Settings", self.google_meet_account, "google_calendar"
|
||||
)
|
||||
else:
|
||||
calendar = frappe.db.get_value(
|
||||
"Google Calendar", {"user": frappe.session.user, "enable": 1}, "name"
|
||||
)
|
||||
|
||||
if not calendar:
|
||||
frappe.throw(
|
||||
_(
|
||||
"No calendar is configured for the conferencing provider. Please set up a calendar to create events."
|
||||
)
|
||||
)
|
||||
|
||||
if calendar:
|
||||
event = self.create_event()
|
||||
self.add_event_participants(event, calendar)
|
||||
frappe.db.set_value(self.doctype, self.name, "event", event.name)
|
||||
self.add_event_participants(event, calendar)
|
||||
self.sync_with_google_calendar(event, calendar)
|
||||
|
||||
def _create_google_meet_event(self):
|
||||
google_meet_settings = frappe.get_doc("LMS Google Meet Settings", self.google_meet_account)
|
||||
calendar = google_meet_settings.google_calendar
|
||||
|
||||
if not calendar:
|
||||
frappe.throw(_("Google Calendar is not configured for this Google Meet account."))
|
||||
|
||||
event = self.create_event()
|
||||
|
||||
event.reload()
|
||||
event.update(
|
||||
{
|
||||
"sync_with_google_calendar": 1,
|
||||
"add_video_conferencing": 1,
|
||||
"google_calendar": calendar,
|
||||
"description": self._build_event_description(),
|
||||
}
|
||||
)
|
||||
event.save()
|
||||
event.reload()
|
||||
|
||||
meet_link = event.google_meet_link
|
||||
frappe.db.set_value(
|
||||
self.doctype,
|
||||
self.name,
|
||||
{
|
||||
"event": event.name,
|
||||
"join_url": meet_link or "",
|
||||
"start_url": meet_link or "",
|
||||
},
|
||||
)
|
||||
|
||||
if not meet_link:
|
||||
frappe.msgprint(
|
||||
_(
|
||||
"The Meet link is not yet available. It will be generated once Google Calendar syncs the event. Please refresh the page after a few moments."
|
||||
),
|
||||
indicator="orange",
|
||||
alert=True,
|
||||
)
|
||||
|
||||
self._add_google_meet_participants(event, calendar)
|
||||
|
||||
def _add_google_meet_participants(self, event, calendar):
|
||||
from frappe.integrations.doctype.google_calendar.google_calendar import get_google_calendar_object
|
||||
|
||||
attendees = []
|
||||
for participant in self._get_participants():
|
||||
email = frappe.db.get_value("User", participant, "email")
|
||||
if not email:
|
||||
continue
|
||||
attendees.append({"email": email})
|
||||
|
||||
if not attendees:
|
||||
return
|
||||
|
||||
try:
|
||||
google_calendar_api, account = get_google_calendar_object(calendar)
|
||||
google_calendar_api.events().patch(
|
||||
calendarId=event.google_calendar_id,
|
||||
eventId=event.google_calendar_event_id,
|
||||
body={
|
||||
"attendees": attendees,
|
||||
"guestsCanSeeOtherGuests": False,
|
||||
},
|
||||
sendUpdates="all",
|
||||
).execute()
|
||||
except Exception:
|
||||
frappe.log_error(title=_("Google Meet - Failed to add participants to calendar event"))
|
||||
frappe.msgprint(
|
||||
_(
|
||||
"Live class was created but calendar invites could not be sent to participants. You may need to share the Meet link manually."
|
||||
),
|
||||
indicator="orange",
|
||||
alert=True,
|
||||
)
|
||||
if self.conferencing_provider == "Google Meet":
|
||||
self.add_video_conferencing_to_event(event)
|
||||
|
||||
def create_event(self):
|
||||
start = f"{self.date} {self.time}"
|
||||
|
||||
event = frappe.get_doc(
|
||||
event = frappe.new_doc("Event")
|
||||
event.update(
|
||||
{
|
||||
"doctype": "Event",
|
||||
"subject": f"Live Class on {self.title}",
|
||||
@@ -164,11 +101,12 @@ class LMSLiveClass(Document):
|
||||
"ends_on": get_datetime(start) + timedelta(minutes=cint(self.duration)),
|
||||
}
|
||||
)
|
||||
|
||||
event.save()
|
||||
return event
|
||||
|
||||
def add_event_participants(self, event, calendar, add_video_conferencing=False):
|
||||
for participant in self._get_participants():
|
||||
for participant in self.get_participants():
|
||||
frappe.get_doc(
|
||||
{
|
||||
"doctype": "Event Participants",
|
||||
@@ -181,20 +119,36 @@ class LMSLiveClass(Document):
|
||||
}
|
||||
).save()
|
||||
|
||||
def sync_with_google_calendar(self, event, calendar):
|
||||
event.reload()
|
||||
|
||||
update_data = {
|
||||
"sync_with_google_calendar": 1,
|
||||
"google_calendar": calendar,
|
||||
"description": self._build_event_description(),
|
||||
"description": self.build_event_description(),
|
||||
}
|
||||
|
||||
if add_video_conferencing:
|
||||
update_data["add_video_conferencing"] = 1
|
||||
|
||||
event.update(update_data)
|
||||
event.save()
|
||||
|
||||
def add_video_conferencing_to_event(self, event):
|
||||
event.reload()
|
||||
event.update(
|
||||
{
|
||||
"add_video_conferencing": 1,
|
||||
}
|
||||
)
|
||||
event.save()
|
||||
event.reload()
|
||||
google_meet_link = event.google_meet_link
|
||||
if google_meet_link:
|
||||
frappe.db.set_value(
|
||||
self.doctype,
|
||||
self.name,
|
||||
{
|
||||
"start_url": google_meet_link,
|
||||
"join_url": google_meet_link,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def send_live_class_reminder():
|
||||
classes = frappe.get_all(
|
||||
|
||||
@@ -129,13 +129,6 @@ class TestLMSLiveClass(BaseTestUtils):
|
||||
self.assertEqual(event.sync_with_google_calendar, 1)
|
||||
self.assertEqual(event.add_video_conferencing, 1)
|
||||
self.assertEqual(event.google_calendar, self.google_calendar.name)
|
||||
|
||||
def test_google_meet_live_class_event_has_correct_times(self):
|
||||
"""The linked Event should have correct start and end times."""
|
||||
live_class = self._create_live_class()
|
||||
live_class.reload()
|
||||
|
||||
event = frappe.get_doc("Event", live_class.event)
|
||||
self.assertIn("10:00", str(event.starts_on))
|
||||
self.assertIn("11:00", str(event.ends_on))
|
||||
|
||||
@@ -183,31 +176,6 @@ class TestLMSLiveClass(BaseTestUtils):
|
||||
self.google_meet_settings.google_calendar = old_calendar
|
||||
self.google_meet_settings.save()
|
||||
|
||||
def test_zoom_live_class_not_affected(self):
|
||||
"""Creating a Zoom-style live class should still work (regression test)."""
|
||||
live_class = frappe.get_doc(
|
||||
{
|
||||
"doctype": "LMS Live Class",
|
||||
"title": f"Zoom Class {frappe.generate_hash(length=6)}",
|
||||
"host": "Administrator",
|
||||
"date": add_days(nowdate(), 1),
|
||||
"time": "14:00:00",
|
||||
"duration": 45,
|
||||
"timezone": "Asia/Kolkata",
|
||||
"batch_name": self.batch.name,
|
||||
"conferencing_provider": "Zoom",
|
||||
"join_url": "https://zoom.us/j/123456",
|
||||
"start_url": "https://zoom.us/s/123456",
|
||||
}
|
||||
)
|
||||
live_class.insert(ignore_permissions=True)
|
||||
self.cleanup_items.append(("LMS Live Class", live_class.name))
|
||||
|
||||
self.assertTrue(frappe.db.exists("LMS Live Class", live_class.name))
|
||||
self.assertEqual(live_class.join_url, "https://zoom.us/j/123456")
|
||||
|
||||
# --- T10: Unit tests for event update and cancellation sync ---
|
||||
|
||||
def test_update_live_class_date_updates_event(self):
|
||||
"""Rescheduling a live class should update the linked Event."""
|
||||
live_class = self._create_live_class()
|
||||
@@ -270,40 +238,8 @@ class TestLMSLiveClass(BaseTestUtils):
|
||||
(t, n) for t, n in self.cleanup_items if not (t == "LMS Live Class" and n == live_class.name)
|
||||
]
|
||||
frappe.delete_doc("LMS Live Class", live_class.name, force=True)
|
||||
|
||||
self.assertFalse(frappe.db.exists("Event", event_name))
|
||||
|
||||
def test_delete_zoom_live_class_with_event(self):
|
||||
"""Deleting a Zoom live class with a linked event should also delete the event (regression)."""
|
||||
live_class = self._create_live_class(provider="Zoom")
|
||||
# Zoom classes created via direct insert won't have an event from calendar flow,
|
||||
# but if one is set manually, on_trash should clean it up
|
||||
event = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Event",
|
||||
"subject": "Test Zoom Event",
|
||||
"event_type": "Public",
|
||||
"starts_on": f"{add_days(nowdate(), 1)} 14:00:00",
|
||||
"ends_on": f"{add_days(nowdate(), 1)} 15:00:00",
|
||||
}
|
||||
)
|
||||
event.insert(ignore_permissions=True)
|
||||
self.cleanup_items.append(("Event", event.name))
|
||||
|
||||
frappe.db.set_value("LMS Live Class", live_class.name, "event", event.name)
|
||||
live_class.reload()
|
||||
|
||||
self.cleanup_items = [
|
||||
(t, n) for t, n in self.cleanup_items if not (t == "LMS Live Class" and n == live_class.name)
|
||||
]
|
||||
# Remove event from cleanup too since on_trash will delete it
|
||||
self.cleanup_items = [(t, n) for t, n in self.cleanup_items if not (t == "Event" and n == event.name)]
|
||||
frappe.delete_doc("LMS Live Class", live_class.name, force=True)
|
||||
|
||||
self.assertFalse(frappe.db.exists("Event", event.name))
|
||||
|
||||
# --- T11: Integration tests for end-to-end workflow ---
|
||||
|
||||
def test_batch_validation_google_meet_without_account(self):
|
||||
"""Saving a batch with Google Meet provider but no account should fail."""
|
||||
self.batch.conferencing_provider = "Google Meet"
|
||||
@@ -311,7 +247,6 @@ class TestLMSLiveClass(BaseTestUtils):
|
||||
with self.assertRaises(frappe.exceptions.ValidationError):
|
||||
self.batch.save()
|
||||
|
||||
# Reset
|
||||
self.batch.reload()
|
||||
|
||||
def test_batch_validation_google_meet_with_valid_account(self):
|
||||
@@ -324,7 +259,6 @@ class TestLMSLiveClass(BaseTestUtils):
|
||||
self.assertEqual(self.batch.conferencing_provider, "Google Meet")
|
||||
self.assertEqual(self.batch.google_meet_account, self.google_meet_settings.name)
|
||||
|
||||
# Reset
|
||||
self.batch.conferencing_provider = ""
|
||||
self.batch.google_meet_account = ""
|
||||
self.batch.save()
|
||||
@@ -335,23 +269,4 @@ class TestLMSLiveClass(BaseTestUtils):
|
||||
self.batch.zoom_account = ""
|
||||
with self.assertRaises(frappe.exceptions.ValidationError):
|
||||
self.batch.save()
|
||||
|
||||
# Reset
|
||||
self.batch.reload()
|
||||
|
||||
def test_update_attendance_skips_google_meet(self):
|
||||
"""The Zoom attendance scheduler should skip Google Meet classes."""
|
||||
live_class = self._create_live_class()
|
||||
live_class.reload()
|
||||
|
||||
# The update_attendance function uses conferencing_provider != "Google Meet"
|
||||
# to filter out Google Meet classes from Zoom attendance processing.
|
||||
# Verify a Google Meet class is excluded by that filter.
|
||||
past_classes = frappe.get_all(
|
||||
"LMS Live Class",
|
||||
{
|
||||
"conferencing_provider": ["!=", "Google Meet"],
|
||||
},
|
||||
pluck="name",
|
||||
)
|
||||
self.assertNotIn(live_class.name, past_classes)
|
||||
|
||||
@@ -118,7 +118,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2026-02-25 12:36:40.110346",
|
||||
"modified_by": "sayali@frappe.io",
|
||||
"modified_by": "Administrator",
|
||||
"module": "LMS",
|
||||
"name": "LMS Quiz Submission",
|
||||
"owner": "Administrator",
|
||||
|
||||
Reference in New Issue
Block a user