Merge pull request #2165 from frappe/develop

merge `develop` into `main-hotifx`
This commit is contained in:
Jannat Patel
2026-03-05 10:30:31 +05:30
committed by GitHub
65 changed files with 2102 additions and 517 deletions

View File

@@ -60,6 +60,8 @@ 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/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']
InlineLessonMenu: typeof import('./src/components/Notes/InlineLessonMenu.vue')['default']

View File

@@ -33,7 +33,7 @@
"dayjs": "1.11.10",
"dompurify": "3.2.6",
"feather-icons": "4.28.0",
"frappe-ui": "^0.1.261",
"frappe-ui": "^0.1.264",
"highlight.js": "11.11.1",
"lucide-vue-next": "0.383.0",
"markdown-it": "14.0.0",

View File

@@ -83,7 +83,8 @@
private: true,
}"
:validateFile="
(file) => validateFile(file, assignment.data.type.toLowerCase())
(file) =>
validateFile(file, true, assignment.data.type.toLowerCase())
"
@success="(file) => saveSubmission(file)"
>
@@ -418,11 +419,15 @@ const canGradeSubmission = computed(() => {
})
const canModifyAssignment = computed(() => {
return (
!submissionResource.doc ||
(submissionResource.doc?.owner == user.data?.name &&
submissionResource.doc?.status == 'Not Graded')
)
if (canGradeSubmission.value) {
return true
} else if (
submissionResource.doc?.owner == user.data?.name &&
submissionResource.doc?.status == 'Not Graded'
) {
return true
}
return false
})
const submissionStatusOptions = computed(() => {

View File

@@ -10,7 +10,7 @@
{{ course.data.price }}
</div>
<div v-if="!readOnlyMode">
<div v-if="course.data.membership" class="space-y-2">
<div v-if="course.data.membership" class="space-y-2 mb-8">
<router-link
:to="{
name: 'Lesson',
@@ -46,7 +46,7 @@
},
}"
>
<Button variant="solid" size="md" class="w-full">
<Button variant="solid" size="md" class="w-full mb-8">
<template #prefix>
<CreditCard class="size-4 stroke-1.5" />
</template>
@@ -67,7 +67,7 @@
v-else-if="!isAdmin"
@click="enrollStudent()"
variant="solid"
class="w-full"
class="w-full mb-8"
size="md"
>
<template #prefix>
@@ -91,10 +91,7 @@
</Button>
</div>
<div class="space-y-4">
<div
class="font-medium text-ink-gray-9"
:class="{ 'mt-8': !readOnlyMode }"
>
<div class="font-medium text-ink-gray-9">
{{ __('This course has:') }}
</div>
<div class="flex items-center text-ink-gray-9">

View File

@@ -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>

View File

@@ -53,9 +53,9 @@
</template>
<script setup>
import { Dialog, FormControl, createResource, toast } from 'frappe-ui'
import { Link } from 'frappe-ui/frappe'
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import Link from '@/components/Controls/Link.vue'
const show = defineModel()
const assessmentType = ref(null)

View File

@@ -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')"

View File

@@ -37,10 +37,12 @@
<FormControl
v-model="profile.first_name"
:label="__('First Name')"
:required="true"
/>
<FormControl
v-model="profile.last_name"
:label="__('Last Name')"
:required="true"
/>
<FormControl v-model="profile.headline" :label="__('Headline')" />
@@ -141,7 +143,25 @@ const updateProfile = createResource({
},
})
const validateMandatoryFields = () => {
let missingFields = []
if (!profile.first_name) missingFields.push(__('First Name'))
if (!profile.last_name) missingFields.push(__('Last Name'))
if (!profile.image) missingFields.push(__('Profile Image'))
if (missingFields.length) {
toast.error(
__('Please fill the mandatory fields: {0}').format(
missingFields.join(', ')
)
)
console.error('Missing mandatory fields:', missingFields)
}
return missingFields.length
}
const saveProfile = () => {
let missingMandatoryFields = validateMandatoryFields()
if (missingMandatoryFields) return
profile.bio = sanitizeHTML(profile.bio)
updateProfile.submit(
{},

View File

@@ -67,6 +67,7 @@
/>
</div>
<FormControl
v-if="props.conferencingProvider === 'Zoom'"
v-model="liveClass.auto_recording"
type="select"
:options="getRecordingOptions()"
@@ -99,10 +100,9 @@ const props = defineProps({
type: String,
required: true,
},
zoomAccount: {
type: String,
required: true,
},
zoomAccount: String,
googleMeetAccount: String,
conferencingProvider: String,
})
let liveClass = reactive({
@@ -159,8 +159,23 @@ const createLiveClass = createResource({
},
})
const createGoogleMeetLiveClass = createResource({
url: 'lms.lms.doctype.lms_batch.lms_batch.create_google_meet_live_class',
makeParams(values) {
return {
batch_name: values.batch,
google_meet_account: props.googleMeetAccount,
...values,
}
},
})
const submitLiveClass = (close) => {
return createLiveClass.submit(liveClass, {
const resource =
props.conferencingProvider === 'Google Meet'
? createGoogleMeetLiveClass
: createLiveClass
return resource.submit(liveClass, {
validate() {
validateFormFields()
},

View File

@@ -109,16 +109,14 @@ const account = reactive({
client_secret: '',
})
const props = defineProps({
accountID: {
type: String,
default: 'new',
},
})
const props = defineProps<{
accountID: string | null
}>()
watch(
() => props.accountID,
(val) => {
console.log(props.accountID)
if (val === 'new') {
account.name = ''
account.enabled = false

View File

@@ -465,7 +465,7 @@ watch(
)
const quizSubmission = createResource({
url: 'lms.lms.doctype.lms_quiz.lms_quiz.quiz_summary',
url: 'lms.lms.doctype.lms_quiz.lms_quiz.submit_quiz',
makeParams(values) {
return {
quiz: quiz.data.name,
@@ -538,7 +538,7 @@ const checkAnswer = () => {
url: 'lms.lms.doctype.lms_quiz.lms_quiz.check_answer',
params: {
question: currentQuestion.value,
type: questionDetails.data.type,
question_type: questionDetails.data.type,
answers: JSON.stringify(answers),
},
auto: true,
@@ -569,10 +569,7 @@ const addToLocalStorage = () => {
let quizData = JSON.parse(localStorage.getItem(quiz.data.title))
let questionData = {
question_name: currentQuestion.value,
answer: getAnswers().join(),
is_correct: showAnswers.filter((answer) => {
return answer != undefined
}),
answer: getAnswers(),
}
if (quizData) {

View File

@@ -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" />

View File

@@ -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">

View File

@@ -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>

View File

@@ -0,0 +1,197 @@
<template>
<Dialog
v-model="show"
:options="{
title:
accountID === 'new'
? __('New Google Meet Account')
: __('Edit Google Meet Account'),
size: 'xl',
actions: [
{
label: __('Save'),
variant: 'solid',
onClick: ({ close }) => {
saveAccount(close)
},
},
],
}"
>
<template #body-content>
<div class="mb-4">
<FormControl
v-model="account.enabled"
:label="__('Enabled')"
type="checkbox"
/>
</div>
<div class="grid grid-cols-2 gap-5">
<FormControl
v-model="account.name"
:label="__('Account Name')"
type="text"
:required="true"
/>
<Link
v-model="account.member"
:label="__('Member')"
doctype="Course Evaluator"
:onCreate="(value: string, close: () => void) => openSettings('Members', close)"
:required="true"
/>
<Link
v-model="account.google_calendar"
:label="__('Google Calendar')"
doctype="Google Calendar"
:required="true"
/>
</div>
</template>
</Dialog>
</template>
<script setup lang="ts">
import { call, Dialog, FormControl, toast } from 'frappe-ui'
import { inject, reactive, watch } from 'vue'
import { User } from '@/components/Settings/types'
import { openSettings, cleanError } from '@/utils'
import Link from '@/components/Controls/Link.vue'
import { useTelemetry } from 'frappe-ui/frappe'
interface GoogleMeetAccount {
name: string
account_name: string
enabled: boolean
member: string
google_calendar: string
}
interface GoogleMeetAccounts {
data: GoogleMeetAccount[]
reload: () => void
insert: {
submit: (
data: GoogleMeetAccount,
options: { onSuccess: () => void; onError: (err: any) => void }
) => void
}
setValue: {
submit: (
data: GoogleMeetAccount,
options: { onSuccess: () => void; onError: (err: any) => void }
) => void
}
}
const show = defineModel('show')
const user = inject<User | null>('$user')
const googleMeetAccounts = defineModel<GoogleMeetAccounts>('googleMeetAccounts')
const { capture } = useTelemetry()
const account = reactive({
name: '',
enabled: false,
member: user?.data?.name || '',
google_calendar: '',
})
const props = defineProps({
accountID: {
type: String,
default: 'new',
},
})
watch(
() => props.accountID,
(val) => {
if (val === 'new') {
account.name = ''
account.enabled = false
account.member = user?.data?.name || ''
account.google_calendar = ''
} else if (val && val !== 'new') {
const acc = googleMeetAccounts.value?.data.find((acc) => acc.name === val)
if (acc) {
account.name = acc.name
account.enabled = acc.enabled || false
account.member = acc.member
account.google_calendar = acc.google_calendar
}
}
}
)
const saveAccount = (close: () => void) => {
if (props.accountID == 'new') {
createAccount(close)
} else {
updateAccount(close)
}
}
const createAccount = (close: () => void) => {
googleMeetAccounts.value?.insert.submit(
{
account_name: account.name,
...account,
},
{
onSuccess() {
capture('google_meet_account_linked')
googleMeetAccounts.value?.reload()
close()
toast.success(__('Google Meet Account created successfully'))
},
onError(err) {
console.error(err)
close()
toast.error(
cleanError(err.messages[0]) ||
__('Error creating Google Meet Account')
)
},
}
)
}
const updateAccount = async (close: () => void) => {
if (props.accountID != account.name) {
await renameDoc()
}
setValue(close)
}
const renameDoc = async () => {
await call('frappe.client.rename_doc', {
doctype: 'LMS Google Meet Settings',
old_name: props.accountID,
new_name: account.name,
})
}
const setValue = (close: () => void) => {
googleMeetAccounts.value?.setValue.submit(
{
...account,
name: account.name,
account_name: props.accountID,
},
{
onSuccess() {
googleMeetAccounts.value?.reload()
close()
toast.success(__('Google Meet Account updated successfully'))
},
onError(err: any) {
console.error(err)
close()
toast.error(
cleanError(err.messages[0]) ||
__('Error updating Google Meet Account')
)
},
}
)
}
</script>

View File

@@ -0,0 +1,202 @@
<template>
<div class="flex flex-col min-h-0 text-base">
<div class="flex items-center justify-between mb-5">
<div class="flex flex-col space-y-2">
<div class="text-xl font-semibold text-ink-gray-9">
{{ label }}
</div>
<div class="text-ink-gray-6 leading-5">
{{ __(description) }}
</div>
</div>
<div class="flex items-center space-x-5">
<Button @click="openForm('new')">
<template #prefix>
<Plus class="h-3 w-3 stroke-1.5" />
</template>
{{ __('New') }}
</Button>
</div>
</div>
<div v-if="googleMeetAccounts.data?.length" class="overflow-y-scroll">
<ListView
:columns="columns"
:rows="googleMeetAccounts.data"
row-key="name"
:options="{
showTooltip: false,
onRowClick: (row) => {
openForm(row.name)
},
}"
>
<ListHeader
class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2"
>
<ListHeaderItem :item="item" v-for="item in columns">
<template #prefix="{ item }">
<FeatherIcon
v-if="item.icon"
:name="item.icon"
class="h-4 w-4 stroke-1.5"
/>
</template>
</ListHeaderItem>
</ListHeader>
<ListRows>
<ListRow :row="row" v-for="row in googleMeetAccounts.data">
<template #default="{ column, item }">
<ListRowItem :item="row[column.key]" :align="column.align">
<template #prefix>
<div v-if="column.key == 'member_name'">
<Avatar
class="flex items-center"
:image="row['member_image']"
:label="item"
size="sm"
/>
</div>
</template>
<div v-if="column.key == 'enabled'">
<Badge v-if="row[column.key]" theme="green">
{{ __('Enabled') }}
</Badge>
<Badge v-else theme="gray">
{{ __('Disabled') }}
</Badge>
</div>
<div v-else class="leading-5 text-sm">
{{ row[column.key] }}
</div>
</ListRowItem>
</template>
</ListRow>
</ListRows>
<ListSelectBanner>
<template #actions="{ unselectAll, selections }">
<div class="flex gap-2">
<Button
variant="ghost"
@click="removeAccount(selections, unselectAll)"
>
<Trash2 class="h-4 w-4 stroke-1.5" />
</Button>
</div>
</template>
</ListSelectBanner>
</ListView>
</div>
</div>
<GoogleMeetAccountModal
v-model="showForm"
v-model:googleMeetAccounts="googleMeetAccounts"
:accountID="currentAccount"
/>
</template>
<script setup lang="ts">
import {
Avatar,
Button,
Badge,
call,
createListResource,
FeatherIcon,
ListView,
ListHeader,
ListHeaderItem,
ListRows,
ListRow,
ListRowItem,
ListSelectBanner,
toast,
} from 'frappe-ui'
import { computed, inject, onMounted, ref } from 'vue'
import { Plus, Trash2 } from 'lucide-vue-next'
import { cleanError } from '@/utils'
import { User } from '@/components/Settings/types'
import GoogleMeetAccountModal from '@/components/Settings/GoogleMeetAccountModal.vue'
const user = inject<User | null>('$user')
const showForm = ref(false)
const currentAccount = ref<string | null>(null)
const props = defineProps({
label: String,
description: String,
})
const googleMeetAccounts = createListResource({
doctype: 'LMS Google Meet Settings',
fields: [
'name',
'enabled',
'member',
'member_name',
'member_image',
'google_calendar',
],
cache: ['googleMeetAccounts'],
})
onMounted(() => {
fetchGoogleMeetAccounts()
})
const fetchGoogleMeetAccounts = () => {
if (!user?.data?.is_moderator && !user?.data?.is_evaluator) return
if (!user?.data?.is_moderator) {
googleMeetAccounts.update({
filters: {
member: user.data.name,
},
})
}
googleMeetAccounts.reload()
}
const openForm = (accountID: string) => {
currentAccount.value = accountID
showForm.value = true
}
const removeAccount = (selections, unselectAll) => {
call('lms.lms.api.delete_documents', {
doctype: 'LMS Google Meet Settings',
documents: Array.from(selections),
})
.then(() => {
googleMeetAccounts.reload()
toast.success(__('Google Meet Account deleted successfully'))
unselectAll()
})
.catch((err) => {
toast.error(
cleanError(err.messages[0]) || __('Error deleting Google Meet Account')
)
})
}
const columns = computed(() => {
return [
{
label: __('Member'),
key: 'member_name',
icon: 'user',
},
{
label: __('Account Name'),
key: 'name',
icon: 'video',
},
{
label: __('Status'),
key: 'enabled',
align: 'center',
icon: 'check-square',
},
]
})
</script>

View File

@@ -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>

View File

@@ -131,7 +131,7 @@ watch(newGateway, () => {
let fields = gatewayFields.data || []
arrangeFields(fields)
newGatewayFields.value = makeSections(fields)
prepareGatewayData()
prepareGatewayData(fields)
})
})
@@ -209,13 +209,11 @@ const allGatewayOptions = computed(() => {
return options.map((gateway: string) => ({ label: gateway, value: gateway }))
})
const prepareGatewayData = () => {
const prepareGatewayData = (fields: any[]) => {
newGatewayData.value = {}
if (newGatewayFields.value.length) {
newGatewayFields.value.forEach((field: any) => {
newGatewayData.value[field.fieldname] = field.default || ''
})
}
fields.forEach((field: any) => {
newGatewayData.value[field.name] = field.default || ''
})
}
const makeSections = (fields: any[]) => {

View File

@@ -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">
@@ -88,6 +88,7 @@
import {
Badge,
Button,
call,
createListResource,
FeatherIcon,
ListView,
@@ -97,10 +98,12 @@ import {
ListRow,
ListRowItem,
ListSelectBanner,
toast,
} from 'frappe-ui'
import { computed, ref } from 'vue'
import { Plus, Trash2 } from 'lucide-vue-next'
import PaymentGatewayDetails from '@/components/Settings/PaymentGatewayDetails.vue'
import { cleanError } from '@/utils'
const showForm = ref(false)
const currentGateway = ref(null)
@@ -128,6 +131,23 @@ const openForm = (gatewayID) => {
showForm.value = true
}
const removeAccount = (selections, unselectAll) => {
call('lms.lms.api.delete_documents', {
doctype: 'Payment Gateway',
documents: Array.from(selections),
})
.then(() => {
paymentGateways.reload()
toast.success(__('Payment gateways deleted successfully'))
unselectAll()
})
.catch((err) => {
toast.error(
cleanError(err.messages[0]) || __('Error deleting payment gateways')
)
})
}
const columns = computed(() => {
return [
{

View File

@@ -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" />

View File

@@ -20,6 +20,7 @@
:doctype="field.doctype"
:label="__(field.label)"
:description="__(field.description)"
:required="field.reqd"
/>
<div v-else-if="field.type == 'Code'">
@@ -115,6 +116,7 @@
:rows="field.rows"
:options="field.options"
:description="field.description"
:required="field.reqd"
placeholder=""
/>
</div>

View File

@@ -76,6 +76,7 @@ import PaymentGateways from '@/components/Settings/PaymentGateways.vue'
import Coupons from '@/components/Settings/Coupons/Coupons.vue'
import Transactions from '@/components/Settings/Transactions/Transactions.vue'
import ZoomSettings from '@/components/Settings/ZoomSettings.vue'
import GoogleMeetSettings from '@/components/Settings/GoogleMeetSettings.vue'
import Badges from '@/components/Settings/Badges.vue'
const show = defineModel()
@@ -268,34 +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: 'Badges',
description:
@@ -317,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,
@@ -387,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,
@@ -394,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: [
{
@@ -482,6 +498,8 @@ const tabsStructure = computed(() => {
{
label: 'Signup',
icon: 'LogIn',
description:
'Manage the settings related to user signup and registration',
sections: [
{
columns: [
@@ -517,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: [

View File

@@ -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">

View File

@@ -6,7 +6,7 @@
{{ label }}
</div>
<div class="text-ink-gray-6 leading-5">
{{ __(description) }}
{{ __(description || '') }}
</div>
</div>
<div class="flex items-center space-x-5">
@@ -90,6 +90,7 @@
</div>
</div>
<ZoomAccountModal
v-if="showForm"
v-model="showForm"
v-model:zoomAccounts="zoomAccounts"
:accountID="currentAccount"
@@ -100,7 +101,6 @@ import {
Avatar,
Button,
Badge,
call,
createListResource,
FeatherIcon,
ListView,
@@ -112,20 +112,18 @@ import {
ListSelectBanner,
toast,
} from 'frappe-ui'
import { computed, inject, onMounted, ref } from 'vue'
import { computed, onMounted, ref } from 'vue'
import { Plus, Trash2 } from 'lucide-vue-next'
import { cleanError } from '@/utils'
import { User } from '@/components/Settings/types'
import ZoomAccountModal from '@/components/Modals/ZoomAccountModal.vue'
const user = inject<User | null>('$user')
const showForm = ref(false)
const currentAccount = ref<string | null>(null)
const props = defineProps({
label: String,
description: String,
})
const props = defineProps<{
label: string
description?: string
}>()
const zoomAccounts = createListResource({
doctype: 'LMS Zoom Settings',
@@ -147,15 +145,6 @@ onMounted(() => {
})
const fetchZoomAccounts = () => {
if (!user?.data?.is_moderator && !user?.data?.is_evaluator) return
if (!user?.data?.is_moderator) {
zoomAccounts.update({
filters: {
member: user.data.name,
},
})
}
zoomAccounts.reload()
}
@@ -164,21 +153,20 @@ const openForm = (accountID: string) => {
showForm.value = true
}
const removeAccount = (selections, unselectAll) => {
call('lms.lms.api.delete_documents', {
doctype: 'LMS Zoom Settings',
documents: Array.from(selections),
const removeAccount = (selections: Set<string>, unselectAll: () => void) => {
Array.from(selections).forEach((accountID) => {
zoomAccounts.delete.submit(accountID, {
onSuccess() {
toast.success(__('Zoom account deleted successfully'))
fetchZoomAccounts()
unselectAll()
},
onError(err: any) {
toast.error(cleanError(err.messages[0] || err))
console.error(err)
},
})
})
.then(() => {
zoomAccounts.reload()
toast.success(__('Email Templates deleted successfully'))
unselectAll()
})
.catch((err) => {
toast.error(
cleanError(err.messages[0]) || __('Error deleting email templates')
)
})
}
const columns = computed(() => {

View File

@@ -40,7 +40,7 @@
class="flex items-center text-sm text-ink-gray-5 my-1"
>
<span class="grid h-5 w-6 flex-shrink-0 place-items-center">
<ChevronRight
<ChevronsRight
class="h-4 w-4 stroke-1.5 text-ink-gray-9 transition-all duration-300 ease-in-out"
:class="{ 'rotate-90': !sidebarStore.isWebpagesCollapsed }"
/>
@@ -90,6 +90,56 @@
)
}}
</div>
<div
v-if="
isStudent && !profileIsComplete && !sidebarStore.isSidebarCollapsed
"
class="flex flex-col gap-3 text-ink-gray-9 py-2.5 px-3 bg-surface-white shadow-sm rounded-md"
>
<div class="flex flex-col text-p-sm gap-1">
<div class="inline-flex gap-1">
<User class="h-4 my-0.5 shrink-0" />
<div class="font-medium">
{{ __('Complete your profile') }}
</div>
</div>
<div class="text-ink-gray-7 leading-5">
{{ __('Highlight what makes you unique and show your skills.') }}
</div>
</div>
<router-link
:to="{
name: 'Profile',
params: {
username: userResource.data?.username,
},
}"
>
<Button :label="__('My Profile')" class="w-full">
<template #prefix>
<ChevronsRight class="h-4 w-4 text-ink-gray-7 stroke-1.5" />
</template>
</Button>
</router-link>
</div>
<Tooltip
v-if="
isStudent && !profileIsComplete && sidebarStore.isSidebarCollapsed
"
:text="__('Complete your profile')"
>
<router-link
:to="{
name: 'Profile',
params: {
username: userResource.data?.username,
},
}"
class="flex items-center justify-center"
>
<User class="size-4 stroke-1.5 text-ink-gray-7 cursor-pointer" />
</router-link>
</Tooltip>
<TrialBanner
v-if="
userResource.data?.is_system_manager && userResource.data?.is_fc_site
@@ -210,15 +260,18 @@ import {
markRaw,
h,
onUnmounted,
computed,
} from 'vue'
import {
BookOpen,
CircleAlert,
ChevronRight,
ChevronsRight,
Plus,
CircleHelp,
FolderTree,
FileText,
User,
UserPlus,
Users,
BookText,
@@ -613,6 +666,18 @@ const redirectToWebsite = () => {
window.open('https://frappe.io/learning', '_blank')
}
const isStudent = computed(() => {
return userResource.data?.is_student
})
const profileIsComplete = computed(() => {
return (
userResource.data?.user_image &&
userResource.data?.headline &&
userResource.data?.bio
)
})
onUnmounted(() => {
socket.off('publish_lms_notifications')
})

View File

@@ -48,7 +48,7 @@ const apps = createResource({
name: 'frappe',
logo: '/assets/lms/images/desk.png',
title: __('Desk'),
route: '/desk/lms',
route: '/desk/learning',
},
]
data.map((app) => {

View File

@@ -41,55 +41,36 @@
<span class="font-semibold text-ink-gray-9 leading-5">
{{ evl.course_title }}
</span>
<Menu
<Dropdown
v-if="evl.date > dayjs().format()"
as="div"
class="relative inline-block text-left"
:options="[
{
label: __('Cancel'),
icon: Ban,
onClick() {
cancelEvaluation(evl)
},
},
]"
placement="left"
side="left"
>
<div>
<MenuButton class="inline-flex w-full justify-center">
<EllipsisVertical class="w-4 h-4 stroke-1.5" />
</MenuButton>
</div>
<transition
enter-active-class="transition duration-100 ease-out"
enter-from-class="transform scale-95 opacity-0"
enter-to-class="transform scale-100 opacity-100"
leave-active-class="transition duration-75 ease-in"
leave-from-class="transform scale-100 opacity-100"
leave-to-class="transform scale-95 opacity-0"
>
<MenuItems
class="absolute mt-2 w-32 rounded-md bg-surface-white border p-1.5"
>
<MenuItem v-slot="{ active }">
<Button
variant="ghost"
class="w-full"
@click="cancelEvaluation(evl)"
>
<template #prefix>
<Ban
:active="active"
class="size-4 stroke-1.5"
aria-hidden="true"
/>
</template>
{{ __('Cancel') }}
</Button>
</MenuItem>
</MenuItems>
</transition>
</Menu>
<template v-slot="{ open }">
<Button variant="ghost">
<template #icon>
<EllipsisVertical class="w-4 h-4 stroke-1.5" />
</template>
</Button>
</template>
</Dropdown>
</div>
<div class="flex items-center mb-2">
<div class="flex items-center mb-3">
<Calendar class="w-4 h-4 stroke-1.5" />
<span class="ml-2">
{{ dayjs(evl.date).format('DD MMMM YYYY') }}
</span>
</div>
<div class="flex items-center mb-2">
<div class="flex items-center mb-3">
<Clock class="w-4 h-4 stroke-1.5" />
<span class="ml-2">
{{ formatTime(evl.start_time) }}
@@ -139,9 +120,8 @@ import {
} from 'lucide-vue-next'
import { inject, ref, getCurrentInstance, computed } from 'vue'
import { formatTime } from '@/utils'
import { Button, createListResource, call, toast } from 'frappe-ui'
import { Button, createListResource, call, Dropdown, toast } from 'frappe-ui'
import EvaluationModal from '@/components/Modals/EvaluationModal.vue'
import { Menu, MenuButton, MenuItems, MenuItem } from '@headlessui/vue'
const dayjs = inject('$dayjs')
const user = inject('$user')
@@ -186,6 +166,7 @@ const upcoming_evals = createListResource({
'evaluator_name',
'course_title',
'member',
'member_name',
'google_meet_link',
],
orderBy: 'date',
@@ -220,7 +201,7 @@ const endDateHasPassed = computed(() => {
const cancelEvaluation = (evl) => {
$dialog({
title: __('Cancel this evaluation?'),
title: __('Confirm Cancellation?'),
message: __(
'Are you sure you want to cancel this evaluation? This action cannot be undone.'
),

View File

@@ -191,7 +191,7 @@ const addToTabs = (label, component, icon) => {
}
const isAdmin = computed(() => {
return user.data?.is_moderator || batch.data?.is_evaluator
return user.data?.is_moderator || user.data?.is_evaluator
})
const isStudent = computed(() => {

View File

@@ -165,24 +165,48 @@
"
/>
</div>
<div class="space-y-5">
<Link
doctype="LMS Zoom Settings"
:label="__('Zoom Account')"
v-model="batchDetail.doc.zoom_account"
:onCreate="
(value, close) => {
openSettings('Zoom Accounts', close)
}
"
/>
<Uploader
v-model="batchDetail.doc.video_link"
:label="__('Preview Video')"
type="video"
:required="false"
/>
</div>
<Uploader
v-model="batchDetail.doc.video_link"
:label="__('Preview Video')"
type="video"
:required="false"
/>
</div>
</div>
<div class="px-5 pb-5 space-y-5 border-b mb-5">
<div class="text-lg text-ink-gray-9 font-semibold mb-4">
{{ __('Conferencing') }}
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
<FormControl
v-model="batchDetail.doc.conferencing_provider"
type="select"
:options="conferencingOptions"
:label="__('Conferencing Provider')"
/>
<Link
v-if="batchDetail.doc.conferencing_provider === 'Zoom'"
doctype="LMS Zoom Settings"
:label="__('Zoom Account')"
v-model="batchDetail.doc.zoom_account"
:onCreate="
(value, close) => {
openSettings('Zoom Accounts', close)
}
"
/>
<Link
v-if="batchDetail.doc.conferencing_provider === 'Google Meet'"
doctype="LMS Google Meet Settings"
:label="__('Google Meet Account')"
v-model="batchDetail.doc.google_meet_account"
:onCreate="
(value, close) => {
openSettings('Google Meet Accounts', close)
}
"
/>
</div>
</div>
@@ -463,14 +487,31 @@ const trashBatch = (close) => {
})
}
const conferencingOptions = computed(() => {
return [
{
label: '',
value: '',
},
{
label: __('Zoom'),
value: 'Zoom',
},
{
label: __('Google Meet'),
value: 'Google Meet',
},
]
})
const mediumOptions = computed(() => {
return [
{
label: 'Online',
label: __('Online'),
value: 'Online',
},
{
label: 'Offline',
label: __('Offline'),
value: 'Offline',
},
]

View File

@@ -1,14 +1,14 @@
<template>
<div class="p-5">
<div
v-if="isAdmin() && !batch.data?.zoom_account"
v-if="isAdmin() && !hasProviderAccount()"
class="flex lg:items-center space-x-2 mb-5 bg-surface-amber-1 px-3 py-2 rounded-lg text-ink-amber-3"
>
<AlertCircle class="size-7 md:size-4 stroke-1.5" />
<span class="leading-5">
{{
__(
'Link a Zoom account to this batch from the Settings tab to create live classes'
'Please select a conferencing provider and add an account to the batch to create live classes.'
)
}}
</span>
@@ -64,12 +64,12 @@
</span>
</div>
<div
v-if="canAccessClass(cls)"
v-if="canAccessClass(cls) && cls.join_url"
class="flex items-center space-x-2 text-ink-gray-9 mt-auto"
>
<a
v-if="user.data?.is_moderator || user.data?.is_evaluator"
:href="cls.start_url"
:href="cls.start_url || cls.join_url"
target="_blank"
class="cursor-pointer inline-flex items-center justify-center gap-2 transition-colors focus:outline-none text-ink-gray-8 bg-surface-gray-2 hover:bg-surface-gray-3 active:bg-surface-gray-4 focus-visible:ring focus-visible:ring-outline-gray-3 h-7 text-base px-2 rounded"
:class="cls.join_url ? 'w-full' : 'w-1/2'"
@@ -111,6 +111,8 @@
v-model="showLiveClassModal"
:batch="batch.data?.name"
:zoomAccount="batch.data?.zoom_account"
:googleMeetAccount="batch.data?.google_meet_account"
:conferencingProvider="batch.data?.conferencing_provider"
v-model:reloadLiveClasses="liveClasses"
/>
@@ -165,6 +167,8 @@ const liveClasses = createListResource({
'start_url',
'join_url',
'owner',
'conferencing_provider',
'batch_name',
],
orderBy: 'date',
auto: true,
@@ -174,9 +178,20 @@ const openLiveClassModal = () => {
showLiveClassModal.value = true
}
const hasProviderAccount = () => {
const data = props.batch.data
if (data?.conferencing_provider === 'Zoom' && data?.zoom_account) return true
if (
data?.conferencing_provider === 'Google Meet' &&
data?.google_meet_account
)
return true
return false
}
const canCreateClass = () => {
if (readOnlyMode) return false
if (!props.batch.data?.zoom_account) return false
if (!hasProviderAccount()) return false
return isAdmin()
}
@@ -209,8 +224,8 @@ const hasClassEnded = (cls) => {
const openAttendanceModal = (cls) => {
if (!isAdmin()) return
if (cls.attendees <= 0) return
showAttendance.value = true
attendanceFor.value = cls
showAttendance.value = true
}
</script>
<style>

View File

@@ -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,9 +116,9 @@
<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 } from '@/utils'
import { cleanError, openSettings, sanitizeHTML, escapeHTML } from '@/utils'
import Link from '@/components/Controls/Link.vue'
import MultiSelect from '@/components/Controls/MultiSelect.vue'
@@ -127,7 +132,22 @@ const props = defineProps<{
batches: any
}>()
const batch = ref({
type Batch = {
title: string
start_date: string | null
end_date: string | null
start_time: string | null
end_time: string | null
timezone: string | null
description: string
batch_details: string
instructors: string[]
category: string | null
seat_count: number
medium: string | null
}
const batch = ref<Batch>({
title: '',
start_date: null,
end_date: null,
@@ -139,9 +159,26 @@ const batch = ref({
instructors: [],
category: null,
seat_count: 0,
medium: null,
})
const validateFields = () => {
batch.value.description = sanitizeHTML(batch.value.description)
Object.keys(batch.value).forEach((key) => {
if (
key != 'description' &&
typeof batch.value[key as keyof Batch] === 'string'
) {
batch.value[key as keyof Batch] = escapeHTML(
batch.value[key as keyof Batch] as string
)
}
})
}
const saveBatch = (close: () => void = () => {}) => {
validateFields()
props.batches.insert.submit(
{
...batch.value,
@@ -197,4 +234,17 @@ onBeforeUnmount(() => {
data: batch.value,
})
})
const mediumOptions = computed(() => {
return [
{
label: __('Online'),
value: 'Online',
},
{
label: __('Offline'),
value: 'Offline',
},
]
})
</script>

View File

@@ -214,7 +214,7 @@
/>
</div>
<div
class="divide-y max-h-[43vh divide-outline-gray-modals text-ink-gray-7 overflow-y-auto"
class="divide-y max-h-[40vh] divide-outline-gray-modals text-ink-gray-7 overflow-y-auto"
>
<div
v-for="progress in lessonProgress.data"

View File

@@ -164,7 +164,7 @@
:label="__('Preview Video')"
:placeholder="
__(
'Paste the youtube link of a short video introducing the course'
'Paste a YouTube link of a short video introducing the course'
)
"
@input="makeFormDirty()"

View File

@@ -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>
@@ -77,7 +78,7 @@ import { Button, Dialog, FormControl, TextEditor, toast } from 'frappe-ui'
import { useOnboarding, useTelemetry } from 'frappe-ui/frappe'
import { inject, onMounted, onBeforeUnmount, ref } from 'vue'
import { useRouter } from 'vue-router'
import { cleanError, openSettings } from '@/utils'
import { cleanError, openSettings, sanitizeHTML, escapeHTML } from '@/utils'
import Link from '@/components/Controls/Link.vue'
import MultiSelect from '@/components/Controls/MultiSelect.vue'
import Uploader from '@/components/Controls/Uploader.vue'
@@ -87,12 +88,22 @@ const router = useRouter()
const { capture } = useTelemetry()
const { updateOnboardingStep } = useOnboarding('learning')
const user = inject<any>('$user')
const courseCreated = ref(false)
const props = defineProps<{
courses: any
}>()
const course = ref({
type Course = {
title: string
short_introduction: string
description: string
instructors: string[]
category: string | null
image: string | null
}
const course = ref<Course>({
title: '',
short_introduction: '',
description: '',
@@ -101,7 +112,23 @@ const course = ref({
image: null,
})
const validateFields = () => {
course.value.description = sanitizeHTML(course.value.description)
Object.keys(course.value).forEach((key) => {
if (
key != 'description' &&
typeof course.value[key as keyof Course] === 'string'
) {
course.value[key as keyof Course] = escapeHTML(
course.value[key as keyof Course] as string
)
}
})
}
const saveCourse = (close: () => void = () => {}) => {
validateFields()
props.courses.insert.submit(
{
...course.value,
@@ -114,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 },
@@ -153,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>

View File

@@ -3,7 +3,7 @@
v-model="show"
:options="{
title: __('Student Progress'),
size: hasAssessmentData ? '3xl' : 'xl',
size: hasAssessmentData ? '4xl' : 'xl',
}"
>
<template #body-content>
@@ -15,7 +15,7 @@
:label="student?.member_name"
size="xl"
/>
<div>
<div class="space-y-1">
<div class="font-semibold">
{{ student?.member_name }}
</div>
@@ -77,16 +77,22 @@
v-if="assessmentProgress.data?.quizzes?.length"
class="border border-outline-gray-modals rounded-lg px-3 pt-3 h-fit"
>
<div>
<div class="text-ink-gray-5 mb-5">
<div class="grid grid-cols-4 gap-5 text-ink-gray-5 mb-5">
<div class="col-span-2">
{{ __('Quiz Progress') }}
</div>
<div>
{{ __('Score') }}
</div>
<div>
{{ __('Percentage') }}
</div>
</div>
<div
v-for="quiz in assessmentProgress.data.quizzes"
class="flex justify-between text-sm py-2 my-1"
class="grid grid-cols-4 gap-15 text-sm py-1 my-1"
>
<div>
<div class="col-span-2 leading-5">
{{ quiz.quiz_title }}
</div>
<div>

View File

@@ -1,5 +1,112 @@
<template>
<div>
<div class="mt-10 space-y-10">
<div v-if="evals?.data?.length">
<div class="font-semibold text-lg text-ink-gray-9 mb-3">
{{ __('Upcoming Evaluations') }}
</div>
<div class="grid grid-cols-1 md:grid-cols-4 gap-5">
<div
v-for="evaluation in evals?.data"
class="border hover:border-outline-gray-3 rounded-md p-3 flex flex-col h-full cursor-pointer"
@click="redirectToProfile()"
>
<div class="font-semibold text-ink-gray-9 text-lg leading-5 mb-3">
{{ evaluation.course_title }}
</div>
<div class="text-ink-gray-7">
<div class="flex items-center mb-3">
<Calendar class="w-4 h-4 stroke-1.5" />
<span class="ml-2">
{{ dayjs(evaluation.date).format('DD MMMM YYYY') }}
</span>
</div>
<div class="flex items-center mb-3">
<Clock class="w-4 h-4 stroke-1.5" />
<span class="ml-2">
{{ formatTime(evaluation.start_time) }}
</span>
</div>
<div class="flex items-center">
<GraduationCap class="w-4 h-4 stroke-1.5" />
<span class="ml-2">
{{ evaluation.member_name }}
</span>
</div>
</div>
</div>
</div>
</div>
<div v-if="liveClasses?.data?.length">
<div class="font-semibold text-lg text-ink-gray-9 mb-3">
{{ __('Upcoming Live Classes') }}
</div>
<div class="grid grid-cols-1 md:grid-cols-4 gap-5">
<div
v-for="cls in liveClasses?.data"
class="border hover:border-outline-gray-3 rounded-md p-3"
>
<div class="font-semibold text-ink-gray-9 text-lg leading-5 mb-1">
{{ cls.title }}
</div>
<div class="text-ink-gray-7 leading-5 mb-4">
{{ cls.description }}
</div>
<div class="mt-auto space-y-3 text-ink-gray-7">
<div class="flex items-center space-x-2">
<Calendar class="w-4 h-4 stroke-1.5" />
<span>
{{ dayjs(cls.date).format('DD MMMM YYYY') }}
</span>
</div>
<div class="flex items-center space-x-2">
<Clock class="w-4 h-4 stroke-1.5" />
<span>
{{ formatTime(cls.time) }} -
{{ dayjs(getClassEnd(cls)).format('HH:mm A') }}
</span>
</div>
<div
v-if="canAccessClass(cls)"
class="flex items-center space-x-2 text-ink-gray-9 mt-auto"
>
<a
v-if="user.data?.is_moderator || user.data?.is_evaluator"
:href="cls.start_url"
target="_blank"
class="cursor-pointer inline-flex items-center justify-center gap-2 transition-colors focus:outline-none text-ink-gray-8 bg-surface-gray-2 hover:bg-surface-gray-3 active:bg-surface-gray-4 focus-visible:ring focus-visible:ring-outline-gray-3 h-7 text-base px-2 rounded"
:class="cls.join_url ? 'w-full' : 'w-1/2'"
>
<Monitor class="h-4 w-4 stroke-1.5" />
{{ __('Start') }}
</a>
<a
:href="cls.join_url"
target="_blank"
class="w-full cursor-pointer inline-flex items-center justify-center gap-2 transition-colors focus:outline-none text-ink-gray-8 bg-surface-gray-2 hover:bg-surface-gray-3 active:bg-surface-gray-4 focus-visible:ring focus-visible:ring-outline-gray-3 h-7 text-base px-2 rounded"
>
<Video class="h-4 w-4 stroke-1.5" />
{{ __('Join') }}
</a>
</div>
<Tooltip
v-else-if="hasClassEnded(cls)"
:text="__('This class has ended')"
placement="right"
>
<div class="flex items-center space-x-2 text-ink-amber-3 w-fit">
<Info class="w-4 h-4 stroke-1.5" />
<span>
{{ __('Ended') }}
</span>
</div>
</Tooltip>
</div>
</div>
</div>
</div>
</div>
<div v-if="createdCourses.data?.length" class="mt-10">
<div class="flex items-center justify-between mb-3">
<span class="font-semibold text-lg text-ink-gray-9">
@@ -85,113 +192,6 @@
</Button>
</router-link>
</div>
<div class="grid grid-cols-2 gap-5 mt-10">
<div v-if="evals?.data?.length">
<div class="font-semibold text-lg text-ink-gray-9 mb-3">
{{ __('Upcoming Evaluations') }}
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-5">
<div
v-for="evaluation in evals?.data"
class="border hover:border-outline-gray-3 rounded-md p-3 flex flex-col h-full cursor-pointer"
@click="redirectToProfile()"
>
<div class="font-semibold text-ink-gray-9 text-lg leading-5 mb-1">
{{ evaluation.course_title }}
</div>
<div class="text-ink-gray-7 text-sm">
<div class="flex items-center mb-2">
<Calendar class="w-4 h-4 stroke-1.5" />
<span class="ml-2">
{{ dayjs(evaluation.date).format('DD MMMM YYYY') }}
</span>
</div>
<div class="flex items-center mb-2">
<Clock class="w-4 h-4 stroke-1.5" />
<span class="ml-2">
{{ formatTime(evaluation.start_time) }}
</span>
</div>
<div class="flex items-center">
<GraduationCap class="w-4 h-4 stroke-1.5" />
<span class="ml-2">
{{ evaluation.member_name }}
</span>
</div>
</div>
</div>
</div>
</div>
<div v-if="liveClasses?.data?.length">
<div class="font-semibold text-lg text-ink-gray-9 mb-3">
{{ __('Upcoming Live Classes') }}
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-5">
<div
v-for="cls in liveClasses?.data"
class="border hover:border-outline-gray-3 rounded-md p-3"
>
<div class="font-semibold text-ink-gray-9 text-lg leading-5 mb-1">
{{ cls.title }}
</div>
<div class="text-ink-gray-7 text-sm leading-5 mb-4">
{{ cls.description }}
</div>
<div class="mt-auto space-y-3 text-ink-gray-7 text-sm">
<div class="flex items-center space-x-2">
<Calendar class="w-4 h-4 stroke-1.5" />
<span>
{{ dayjs(cls.date).format('DD MMMM YYYY') }}
</span>
</div>
<div class="flex items-center space-x-2">
<Clock class="w-4 h-4 stroke-1.5" />
<span>
{{ formatTime(cls.time) }} -
{{ dayjs(getClassEnd(cls)).format('HH:mm A') }}
</span>
</div>
<div
v-if="canAccessClass(cls)"
class="flex items-center space-x-2 text-ink-gray-9 mt-auto"
>
<a
v-if="user.data?.is_moderator || user.data?.is_evaluator"
:href="cls.start_url"
target="_blank"
class="cursor-pointer inline-flex items-center justify-center gap-2 transition-colors focus:outline-none text-ink-gray-8 bg-surface-gray-2 hover:bg-surface-gray-3 active:bg-surface-gray-4 focus-visible:ring focus-visible:ring-outline-gray-3 h-7 text-base px-2 rounded"
:class="cls.join_url ? 'w-full' : 'w-1/2'"
>
<Monitor class="h-4 w-4 stroke-1.5" />
{{ __('Start') }}
</a>
<a
:href="cls.join_url"
target="_blank"
class="w-full cursor-pointer inline-flex items-center justify-center gap-2 transition-colors focus:outline-none text-ink-gray-8 bg-surface-gray-2 hover:bg-surface-gray-3 active:bg-surface-gray-4 focus-visible:ring focus-visible:ring-outline-gray-3 h-7 text-base px-2 rounded"
>
<Video class="h-4 w-4 stroke-1.5" />
{{ __('Join') }}
</a>
</div>
<Tooltip
v-else-if="hasClassEnded(cls)"
:text="__('This class has ended')"
placement="right"
>
<div class="flex items-center space-x-2 text-ink-amber-3 w-fit">
<Info class="w-4 h-4 stroke-1.5" />
<span>
{{ __('Ended') }}
</span>
</div>
</Tooltip>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">

View File

@@ -108,7 +108,7 @@
<div class="flex items-center justify-between mb-3">
<span class="font-semibold text-lg text-ink-gray-9">
{{
myBatches.data?.[0].students.includes(user.data?.name)
myBatches.data?.[0].students?.includes(user.data?.name)
? __('My Batches')
: __('Our Upcoming Batches')
}}

View File

@@ -293,7 +293,7 @@
</div>
</div>
<div class="sticky top-10">
<div class="bg-surface-menu-bar py-5 px-2 border-b">
<div class="bg-surface-menu-bar p-5 border-b">
<div class="text-lg font-semibold text-ink-gray-9">
{{ lesson.data.course_title }}
</div>

View File

@@ -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 {

View File

@@ -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,
@@ -650,21 +650,19 @@ export const validateFile = async (
console.error(msg)
return msg
}
if (!file.type.startsWith(`${fileType}/`)) {
return error(__('Only {0} file is allowed.').format(fileType))
}
if (fileType == 'pdf' && extension !== 'pdf') {
if (fileType == 'pdf' && extension != 'pdf') {
return error(__('Only PDF files are allowed.'))
}
if (fileType == 'document' && !['doc', 'docx'].includes(extension)) {
} else if (fileType == 'document' && !['doc', 'docx'].includes(extension)) {
return error(
__('Only document file of type .doc or .docx are allowed.')
)
}
if (file.type === 'image/svg+xml') {
} else if (
['image', 'video'].includes(fileType) &&
!file.type.startsWith(`${fileType}/`)
) {
return error(__('Only {0} file is allowed.').format(fileType))
} else if (file.type === 'image/svg+xml') {
const text = await file.text()
const blacklist = [
@@ -696,7 +694,6 @@ export const escapeHTML = (text) => {
'"': '&quot;',
"'": '&#39;',
'`': '&#x60;',
'=': '&#x3D;',
}
return String(text).replace(
@@ -709,6 +706,19 @@ export const sanitizeHTML = (text) => {
text = DOMPurify.sanitize(decodeEntities(text), {
ALLOWED_TAGS: [
'b',
'br',
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
'table',
'thead',
'tbody',
'tr',
'th',
'td',
'i',
'em',
'strong',
@@ -719,6 +729,7 @@ export const sanitizeHTML = (text) => {
'ol',
'li',
'img',
'blockquote',
],
ALLOWED_ATTR: ['href', 'target', 'src'],
})

View File

@@ -55,7 +55,7 @@ def get_user_info():
user = frappe.db.get_value(
"User",
frappe.session.user,
["name", "email", "enabled", "user_image", "full_name", "user_type", "username"],
["name", "email", "enabled", "user_image", "full_name", "user_type", "username", "bio", "headline"],
as_dict=1,
)
user["roles"] = frappe.get_roles(user.name)
@@ -702,7 +702,13 @@ def save_certificate_details(
@frappe.whitelist()
def delete_documents(doctype: str, documents: list):
frappe.only_for("Moderator")
meta = frappe.get_meta(doctype)
non_lms_allowed = ["Payment Gateway", "Email Template"]
if meta.module != "LMS" and doctype not in non_lms_allowed:
frappe.throw(_("Deletion not allowed for {0}").format(doctype))
for doc in documents:
if not isinstance(doc, str) or not doc.strip():
frappe.throw(_("Invalid document name"))
frappe.delete_doc(doctype, doc)
@@ -751,13 +757,25 @@ def get_transformed_fields(meta: list, data: dict = None):
else:
fieldtype = row.fieldtype
transformed_fields.append(
{
"label": row.label,
"name": row.fieldname,
"type": fieldtype,
}
)
field = {
"label": row.label,
"name": row.fieldname,
"type": fieldtype,
}
if row.reqd:
field["reqd"] = 1
if row.options:
field["options"] = row.options
if row.default:
field["default"] = row.default
if row.description:
field["description"] = row.description
transformed_fields.append(field)
return transformed_fields
@@ -799,10 +817,9 @@ def get_announcements(batch: str):
is_batch_student = frappe.db.exists(
"LMS Batch Enrollment", {"batch": batch, "member": frappe.session.user}
)
is_moderator = "Moderator" in roles
is_evaluator = "Batch Evaluator" in roles
is_admin = "Moderator" in roles or "Batch Evaluator" in roles
if not (is_batch_student or is_moderator or is_evaluator):
if not (is_batch_student or is_admin):
frappe.throw(
_("You do not have permission to access announcements for this batch."), frappe.PermissionError
)
@@ -1309,6 +1326,15 @@ def cancel_evaluation(evaluation: dict):
if evaluation.member != frappe.session.user:
frappe.throw(_("You do not have permission to cancel this evaluation."), frappe.PermissionError)
if not frappe.db.exists(
"LMS Certificate Request",
{
"name": evaluation.name,
"member": frappe.session.user,
},
):
frappe.throw(_("You do not have permission to cancel this evaluation."), frappe.PermissionError)
frappe.db.set_value("LMS Certificate Request", evaluation.name, "status", "Cancelled")
events = frappe.get_all(
"Event Participants",

View File

@@ -63,6 +63,7 @@ def eval_condition(doc, condition):
@frappe.whitelist()
def assign_badge(badge_name: str):
frappe.only_for(["Moderator", "Course Creator", "Batch Evaluator"])
assignments = []
badge = frappe.db.get_value(
"LMS Badge",

View File

@@ -36,7 +36,9 @@
"medium",
"confirmation_email_template",
"column_break_flwy",
"conferencing_provider",
"zoom_account",
"google_meet_account",
"notification_sent",
"section_break_jedp",
"video_link",
@@ -361,11 +363,25 @@
"label": "Certification"
},
{
"fieldname": "conferencing_provider",
"fieldtype": "Select",
"label": "Conferencing Provider",
"options": "\nZoom\nGoogle Meet"
},
{
"depends_on": "eval:doc.conferencing_provider=='Zoom'",
"fieldname": "zoom_account",
"fieldtype": "Link",
"label": "Zoom Account",
"options": "LMS Zoom Settings"
},
{
"depends_on": "eval:doc.conferencing_provider=='Google Meet'",
"fieldname": "google_meet_account",
"fieldtype": "Link",
"label": "Google Meet Account",
"options": "LMS Google Meet Settings"
},
{
"fieldname": "video_link",
"fieldtype": "Attach",

View File

@@ -36,6 +36,7 @@ class LMSBatch(Document):
self.validate_duplicate_assessments()
self.validate_timetable()
self.validate_evaluation_end_date()
self.validate_conferencing_provider()
def on_update(self):
if self.has_value_changed("published") and self.published:
@@ -126,6 +127,31 @@ class LMSBatch(Document):
if schedule.date < self.start_date or schedule.date > self.end_date:
frappe.throw(_("Row #{0} Date cannot be outside the batch duration.").format(schedule.idx))
def validate_conferencing_provider(self):
if self.is_new() or not self.conferencing_provider:
return
if self.conferencing_provider == "Google Meet":
if not self.google_meet_account:
frappe.throw(_("Please select a Google Meet account for this batch."))
google_meet_settings = frappe.get_doc("LMS Google Meet Settings", self.google_meet_account)
if not google_meet_settings.enabled:
frappe.throw(
_(
"The selected Google Meet account is disabled. Please enable it or select another account."
)
)
if not google_meet_settings.google_calendar:
frappe.throw(
_("The selected Google Meet account does not have a Google Calendar configured.")
)
elif self.conferencing_provider == "Zoom":
if not self.zoom_account:
frappe.throw(_("Please select a Zoom account for this batch."))
def on_payment_authorized(self, payment_status):
if payment_status in ["Authorized", "Completed"]:
update_payment_record("LMS Batch", self.name)
@@ -262,6 +288,49 @@ def create_live_class(
frappe.throw(_("Error creating live class. Please try again. {0}").format(response.text))
@frappe.whitelist()
def create_google_meet_live_class(
batch_name: str,
google_meet_account: str,
title: str,
duration: int,
date: str,
time: str,
timezone: str,
description: str = None,
):
frappe.only_for(["Moderator", "Batch Evaluator"])
google_meet_settings = frappe.get_doc("LMS Google Meet Settings", google_meet_account)
if not google_meet_settings.enabled:
frappe.throw(_("Please enable the Google Meet account to use this feature."))
if not google_meet_settings.google_calendar:
frappe.throw(
_(
"The Google Meet account does not have a Google Calendar configured. Please set up a Google Calendar first."
)
)
class_details = frappe.get_doc(
{
"doctype": "LMS Live Class",
"title": title,
"host": frappe.session.user,
"date": date,
"time": time,
"duration": duration,
"timezone": timezone,
"description": description,
"batch_name": batch_name,
"conferencing_provider": "Google Meet",
"google_meet_account": google_meet_account,
}
)
class_details.save()
return class_details
def authenticate(zoom_account):
zoom = frappe.get_doc("LMS Zoom Settings", zoom_account)
if not zoom.enabled:
@@ -286,6 +355,16 @@ def authenticate(zoom_account):
@frappe.whitelist()
def get_batch_timetable(batch: str):
roles = frappe.get_roles()
is_batch_student = frappe.db.exists(
"LMS Batch Enrollment", {"batch": batch, "member": frappe.session.user}
)
is_admin = "Moderator" in roles or "Batch Evaluator" in roles
if not (is_batch_student or is_admin):
frappe.throw(
_("You do not have permission to access announcements for this batch."), frappe.PermissionError
)
timetable = frappe.get_all(
"LMS Batch Timetable",
filters={"parent": batch},

View File

@@ -110,6 +110,16 @@ def send_confirmation_email(doc: Document):
if isinstance(doc, str):
doc = frappe._dict(json.loads(doc))
roles = frappe.get_roles()
is_admin = "Moderator" in roles or "Batch Evaluator" in roles
is_member = doc.member == frappe.session.user
if not is_member and not is_admin:
frappe.throw(
_("You do not have permission to send confirmation emails for this enrollment."),
frappe.PermissionError,
)
if not doc.confirmation_email_sent:
outgoing_email_account = frappe.get_cached_value(
"Email Account", {"default_outgoing": 1, "enable_outgoing": 1}, "name"

View File

@@ -57,8 +57,10 @@ class LMSCertificate(Document):
def validate_criteria(self):
self.validate_role_of_owner()
self.validate_batch_enrollment()
self.validate_course_enrollment()
if self.batch_name:
self.validate_batch_enrollment()
elif self.course:
self.validate_course_enrollment()
def validate_role_of_owner(self):
roles = frappe.get_roles()
@@ -162,7 +164,8 @@ def is_certified(course):
@frappe.whitelist()
def create_certificate(course: str):
if is_certified(course):
certificate = is_certified(course)
if certificate:
return frappe.db.get_value(
"LMS Certificate", certificate, ["name", "course", "template"], as_dict=True
)

View File

@@ -16,7 +16,7 @@ frappe.ui.form.on("LMS Certificate Request", {
frappe.call({
method: "lms.lms.doctype.lms_certificate_request.lms_certificate_request.setup_calendar_event",
args: {
eval: frm.doc,
eval_name: frm.doc.name,
},
});
});

View File

@@ -161,38 +161,52 @@ def schedule_evals():
},
["name", "member", "member_name", "evaluator", "date", "start_time", "end_time"],
)
for eval in evals:
setup_calendar_event(eval)
for evaluation in evals:
setup_calendar_event(evaluation.name)
@frappe.whitelist()
def setup_calendar_event(eval: str):
if isinstance(eval, str):
eval = frappe._dict(json.loads(eval))
def setup_calendar_event(eval_name: str):
evaluation = frappe.db.get_value(
"LMS Certificate Request",
eval_name,
["name", "member", "member_name", "evaluator", "date", "start_time", "end_time"],
as_dict=1,
)
calendar = frappe.db.get_value("Google Calendar", {"user": eval.evaluator, "enable": 1}, "name")
is_member = evaluation.member == frappe.session.user
roles = frappe.get_roles(frappe.session.user)
is_admin = "Moderator" in roles or "Batch Evaluator" in roles
if not is_member and not is_admin:
frappe.throw(
_("You do not have permission to set up calendar events for this evaluation."),
frappe.PermissionError,
)
calendar = frappe.db.get_value("Google Calendar", {"user": evaluation.evaluator, "enable": 1}, "name")
if calendar:
event = create_event(eval)
add_participants(eval, event)
update_meeting_details(eval, event, calendar)
event = create_event(evaluation)
add_participants(evaluation, event)
update_meeting_details(evaluation, event, calendar)
def create_event(eval: dict):
def create_event(evaluation: dict):
event = frappe.get_doc(
{
"doctype": "Event",
"subject": f"Evaluation of {eval.member_name}",
"starts_on": f"{eval.date} {eval.start_time}",
"ends_on": f"{eval.date} {eval.end_time}",
"subject": f"Evaluation of {evaluation.member_name}",
"starts_on": f"{evaluation.date} {evaluation.start_time}",
"ends_on": f"{evaluation.date} {evaluation.end_time}",
}
)
event.save()
return event
def add_participants(eval: dict, event: Document):
participants = [eval.member, eval.evaluator]
def add_participants(evaluation: dict, event: Document):
participants = [evaluation.member, evaluation.evaluator]
for participant in participants:
contact_name = frappe.db.get_value("Contact", {"email_id": participant}, "name")
frappe.get_doc(
@@ -208,7 +222,7 @@ def add_participants(eval: dict, event: Document):
).save()
def update_meeting_details(eval: dict, event: Document, calendar: str):
def update_meeting_details(evaluation: dict, event: Document, calendar: str):
event.reload()
event.update(
{
@@ -220,7 +234,9 @@ def update_meeting_details(eval: dict, event: Document, calendar: str):
event.save()
event.reload()
frappe.db.set_value("LMS Certificate Request", eval.name, "google_meet_link", event.google_meet_link)
frappe.db.set_value(
"LMS Certificate Request", evaluation.name, "google_meet_link", event.google_meet_link
)
@frappe.whitelist()

View File

@@ -47,7 +47,9 @@ class LMSCourse(Document):
).save(ignore_permissions=True)
def validate_video_link(self):
if self.video_link and "/" in self.video_link:
if self.video_link and "watch?v=" in self.video_link:
self.video_link = self.video_link.split("watch?v=")[-1]
elif self.video_link and "/" in self.video_link:
self.video_link = self.video_link.split("/")[-1]
def validate_status(self):

View File

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

View File

@@ -0,0 +1,123 @@
{
"actions": [],
"allow_rename": 1,
"autoname": "field:account_name",
"creation": "2026-02-04 00:00:00.000000",
"doctype": "DocType",
"engine": "InnoDB",
"field_order": [
"enabled",
"section_break_xfow",
"account_name",
"member",
"member_name",
"member_image",
"column_break_fxxg",
"google_calendar"
],
"fields": [
{
"default": "0",
"fieldname": "enabled",
"fieldtype": "Check",
"label": "Enabled"
},
{
"fieldname": "section_break_xfow",
"fieldtype": "Section Break"
},
{
"fieldname": "account_name",
"fieldtype": "Data",
"label": "Account Name",
"reqd": 1,
"unique": 1
},
{
"fieldname": "member",
"fieldtype": "Link",
"in_list_view": 1,
"in_standard_filter": 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"
},
{
"fieldname": "column_break_fxxg",
"fieldtype": "Column Break"
},
{
"fieldname": "google_calendar",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Google Calendar",
"options": "Google Calendar",
"reqd": 1
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"links": [],
"modified": "2026-02-04 00:00:00.000000",
"modified_by": "Administrator",
"module": "LMS",
"name": "LMS Google Meet Settings",
"naming_rule": "By fieldname",
"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,
"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,
"if_owner": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Batch Evaluator",
"share": 1,
"write": 1
}
],
"row_format": "Dynamic",
"sort_field": "creation",
"sort_order": "DESC",
"states": [],
"track_changes": 1
}

View File

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

View File

@@ -0,0 +1,105 @@
# Copyright (c) 2026, Frappe and Contributors
# See license.txt
import frappe
from frappe.tests import IntegrationTestCase, UnitTestCase
class UnitTestLMSGoogleMeetSettings(UnitTestCase):
"""
Unit tests for LMSGoogleMeetSettings.
Use this class for testing individual functions and methods.
"""
pass
class IntegrationTestLMSGoogleMeetSettings(IntegrationTestCase):
"""
Integration tests for LMSGoogleMeetSettings.
"""
def setUp(self):
self.cleanup_items = []
google_settings = frappe.get_doc("Google Settings")
self._original_google_settings = {
"enable": google_settings.enable,
"client_id": google_settings.client_id,
}
google_settings.enable = 1
google_settings.client_id = "test-client-id"
google_settings.client_secret = "test-client-secret"
google_settings.save(ignore_permissions=True)
def tearDown(self):
for item_type, item_name in reversed(self.cleanup_items):
if frappe.db.exists(item_type, item_name):
try:
frappe.delete_doc(item_type, item_name, force=True)
except Exception:
pass
if hasattr(self, "_original_google_settings"):
google_settings = frappe.get_doc("Google Settings")
google_settings.enable = self._original_google_settings["enable"]
google_settings.client_id = self._original_google_settings["client_id"]
google_settings.client_secret = ""
google_settings.save(ignore_permissions=True)
def _create_google_calendar(self, name="Test Google Calendar"):
if frappe.db.exists("Google Calendar", name):
return frappe.get_doc("Google Calendar", name)
calendar = frappe.get_doc(
{
"doctype": "Google Calendar",
"calendar_name": name,
"user": "Administrator",
"google_account": "test@gmail.com",
}
)
calendar.insert(ignore_permissions=True)
self.cleanup_items.append(("Google Calendar", calendar.name))
return calendar
def test_create_google_meet_settings_with_valid_data(self):
calendar = self._create_google_calendar()
settings = frappe.get_doc(
{
"doctype": "LMS Google Meet Settings",
"account_name": f"Test Meet Account {frappe.generate_hash(length=6)}",
"member": "Administrator",
"google_calendar": calendar.name,
"enabled": 1,
}
)
settings.insert(ignore_permissions=True)
self.cleanup_items.append(("LMS Google Meet Settings", settings.name))
self.assertTrue(frappe.db.exists("LMS Google Meet Settings", settings.name))
self.assertEqual(settings.enabled, 1)
self.assertEqual(settings.google_calendar, calendar.name)
def test_create_google_meet_settings_without_calendar_raises_error(self):
with self.assertRaises(frappe.exceptions.MandatoryError):
settings = frappe.get_doc(
{
"doctype": "LMS Google Meet Settings",
"account_name": f"Test No Calendar {frappe.generate_hash(length=6)}",
"member": "Administrator",
}
)
settings.insert(ignore_permissions=True)
def test_create_google_meet_settings_without_member_raises_error(self):
calendar = self._create_google_calendar()
with self.assertRaises(frappe.exceptions.MandatoryError):
settings = frappe.get_doc(
{
"doctype": "LMS Google Meet Settings",
"account_name": f"Test No Member {frappe.generate_hash(length=6)}",
"google_calendar": calendar.name,
}
)
settings.insert(ignore_permissions=True)

View File

@@ -10,7 +10,9 @@
"field_order": [
"title",
"host",
"conferencing_provider",
"zoom_account",
"google_meet_account",
"batch_name",
"column_break_astv",
"date",
@@ -107,6 +109,7 @@
"read_only": 1
},
{
"depends_on": "eval:doc.conferencing_provider=='Zoom'",
"fieldname": "password",
"fieldtype": "Password",
"label": "Password"
@@ -125,6 +128,7 @@
},
{
"default": "No Recording",
"depends_on": "eval:doc.conferencing_provider=='Zoom'",
"fieldname": "auto_recording",
"fieldtype": "Select",
"label": "Auto Recording",
@@ -137,14 +141,30 @@
"options": "Event",
"read_only": 1
},
{
"fieldname": "conferencing_provider",
"fieldtype": "Select",
"label": "Conferencing Provider",
"options": "Zoom\nGoogle Meet"
},
{
"fieldname": "zoom_account",
"fieldtype": "Link",
"label": "Zoom Account",
"options": "LMS Zoom Settings",
"reqd": 1
"depends_on": "eval:doc.conferencing_provider=='Zoom'",
"mandatory_depends_on": "eval:doc.conferencing_provider=='Zoom'"
},
{
"fieldname": "google_meet_account",
"fieldtype": "Link",
"label": "Google Meet Account",
"options": "LMS Google Meet Settings",
"depends_on": "eval:doc.conferencing_provider=='Google Meet'",
"mandatory_depends_on": "eval:doc.conferencing_provider=='Google Meet'"
},
{
"depends_on": "eval:doc.conferencing_provider=='Zoom'",
"fieldname": "meeting_id",
"fieldtype": "Data",
"label": "Meeting ID"
@@ -160,6 +180,7 @@
"fieldtype": "Section Break"
},
{
"depends_on": "eval:doc.conferencing_provider=='Zoom'",
"fieldname": "uuid",
"fieldtype": "Data",
"label": "UUID"

View File

@@ -1,7 +1,6 @@
# Copyright (c) 2023, Frappe and contributors
# For license information, please see license.txt
import json
from datetime import timedelta
import frappe
@@ -15,17 +14,85 @@ from lms.lms.doctype.lms_batch.lms_batch import authenticate
class LMSLiveClass(Document):
def after_insert(self):
calendar = frappe.db.get_value("Google Calendar", {"user": frappe.session.user, "enable": 1}, "name")
self.create_calendar_event()
def on_update(self):
if not self.event:
return
if (
not self.has_value_changed("date")
and not self.has_value_changed("time")
and not self.has_value_changed("duration")
and not self.has_value_changed("title")
):
return
self._update_linked_event()
def after_delete(self):
if self.event:
frappe.delete_doc("Event", self.event, force=True)
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"
)
participants.append(frappe.session.user)
participants.extend(instructors)
return list(set(participants))
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}. \n\n"
if self.description:
description += f"{self.description}"
return description
def _update_linked_event(self):
event = frappe.get_doc("Event", self.event)
start = f"{self.date} {self.time}"
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.save(ignore_permissions=True)
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)
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}",
@@ -34,20 +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):
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"
)
participants.append(frappe.session.user)
participants.extend(instructors)
participants = list(set(participants))
for participant in participants:
def add_event_participants(self, event, calendar, add_video_conferencing=False):
for participant in self.get_participants():
frappe.get_doc(
{
"doctype": "Event Participants",
@@ -60,16 +119,35 @@ 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(),
}
event.update(update_data)
event.save()
def add_video_conferencing_to_event(self, event):
event.reload()
event.update(
{
"sync_with_google_calendar": 1,
"google_calendar": calendar,
"description": f"A Live Class has been scheduled on {format_date(self.date, 'medium')} at {format_time(self.time, 'hh:mm a')}. Click on this link to join. {self.join_url}. {self.description}",
"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():
@@ -118,6 +196,7 @@ def update_attendance():
{
"uuid": ["is", "set"],
"attendees": ["is", "not set"],
"conferencing_provider": ["!=", "Google Meet"],
},
["name", "uuid", "zoom_account"],
)

View File

@@ -1,9 +1,272 @@
# Copyright (c) 2023, Frappe and Contributors
# See license.txt
# import frappe
from frappe.tests import UnitTestCase
from unittest.mock import MagicMock, patch
import frappe
from frappe.utils import add_days, nowdate
from lms.lms.test_helpers import BaseTestUtils
GOOGLE_CALENDAR_MODULE = "frappe.integrations.doctype.google_calendar.google_calendar"
class TestLMSLiveClass(UnitTestCase):
pass
class TestLMSLiveClass(BaseTestUtils):
"""Tests for LMS Live Class including Google Meet integration."""
def setUp(self):
super().setUp()
# Mock get_google_calendar_object to prevent Frappe's Event hooks
# from calling the real Google Calendar API (no OAuth tokens in CI).
mock_api = MagicMock()
mock_api.events.return_value.insert.return_value.execute.return_value = {
"id": "test-gcal-event-id",
"hangoutLink": "https://meet.google.com/test-link",
"status": "confirmed",
}
mock_api.events.return_value.update.return_value.execute.return_value = {
"id": "test-gcal-event-id",
"hangoutLink": "https://meet.google.com/test-link",
}
mock_api.events.return_value.patch.return_value.execute.return_value = {}
mock_api.events.return_value.delete.return_value.execute.return_value = None
self._gcal_patcher = patch(
f"{GOOGLE_CALENDAR_MODULE}.get_google_calendar_object",
return_value=(mock_api, MagicMock()),
)
self._gcal_patcher.start()
self._setup_course_flow()
self._setup_batch_flow()
self._setup_google_meet()
def tearDown(self):
super().tearDown()
self._gcal_patcher.stop()
if hasattr(self, "_original_google_settings"):
google_settings = frappe.get_doc("Google Settings")
google_settings.enable = self._original_google_settings["enable"]
google_settings.client_id = self._original_google_settings["client_id"]
google_settings.client_secret = ""
google_settings.save(ignore_permissions=True)
def _setup_google_meet(self):
"""Create Google Calendar and Google Meet Settings for testing."""
google_settings = frappe.get_doc("Google Settings")
self._original_google_settings = {
"enable": google_settings.enable,
"client_id": google_settings.client_id,
}
google_settings.enable = 1
google_settings.client_id = "test-client-id"
google_settings.client_secret = "test-client-secret"
google_settings.save(ignore_permissions=True)
calendar_name = f"Test GCal {frappe.generate_hash(length=6)}"
if not frappe.db.exists("Google Calendar", calendar_name):
calendar = frappe.get_doc(
{
"doctype": "Google Calendar",
"calendar_name": calendar_name,
"user": "Administrator",
"google_account": "test@gmail.com",
}
)
calendar.insert(ignore_permissions=True)
self.cleanup_items.append(("Google Calendar", calendar.name))
self.google_calendar = calendar
else:
self.google_calendar = frappe.get_doc("Google Calendar", calendar_name)
account_name = f"Test Meet {frappe.generate_hash(length=6)}"
self.google_meet_settings = frappe.get_doc(
{
"doctype": "LMS Google Meet Settings",
"account_name": account_name,
"member": "Administrator",
"google_calendar": self.google_calendar.name,
"enabled": 1,
}
)
self.google_meet_settings.insert(ignore_permissions=True)
self.cleanup_items.append(("LMS Google Meet Settings", self.google_meet_settings.name))
def _create_live_class(self, provider="Google Meet", **kwargs):
"""Helper to create a live class for testing."""
data = {
"doctype": "LMS Live Class",
"title": f"Test Class {frappe.generate_hash(length=6)}",
"host": "Administrator",
"date": add_days(nowdate(), 1),
"time": "10:00:00",
"duration": 60,
"timezone": "Asia/Kolkata",
"batch_name": self.batch.name,
"conferencing_provider": provider,
}
if provider == "Google Meet":
data["google_meet_account"] = self.google_meet_settings.name
data.update(kwargs)
live_class = frappe.get_doc(data)
live_class.insert(ignore_permissions=True)
self.cleanup_items.append(("LMS Live Class", live_class.name))
return live_class
# --- T9: Unit tests for Google Meet live class creation ---
def test_google_meet_live_class_creates_event(self):
"""Creating a Google Meet live class should create a linked Frappe Event."""
live_class = self._create_live_class()
live_class.reload()
self.assertTrue(live_class.event)
self.assertTrue(frappe.db.exists("Event", live_class.event))
event = frappe.get_doc("Event", live_class.event)
self.assertEqual(event.sync_with_google_calendar, 1)
self.assertEqual(event.add_video_conferencing, 1)
self.assertEqual(event.google_calendar, self.google_calendar.name)
self.assertIn("10:00", str(event.starts_on))
self.assertIn("11:00", str(event.ends_on))
def test_google_meet_disabled_account_raises_error(self):
"""Creating a live class with a disabled Google Meet account should raise an error."""
from lms.lms.doctype.lms_batch.lms_batch import create_google_meet_live_class
self.google_meet_settings.enabled = 0
self.google_meet_settings.save()
with self.assertRaises(frappe.exceptions.ValidationError):
create_google_meet_live_class(
batch_name=self.batch.name,
google_meet_account=self.google_meet_settings.name,
title="Test Disabled",
duration=30,
date=add_days(nowdate(), 1),
time="10:00:00",
timezone="Asia/Kolkata",
)
self.google_meet_settings.enabled = 1
self.google_meet_settings.save()
def test_google_meet_missing_calendar_raises_error(self):
"""Creating a live class with a Google Meet account without a calendar should raise an error."""
from lms.lms.doctype.lms_batch.lms_batch import create_google_meet_live_class
old_calendar = self.google_meet_settings.google_calendar
self.google_meet_settings.google_calendar = ""
self.google_meet_settings.flags.ignore_mandatory = True
self.google_meet_settings.save()
with self.assertRaises(frappe.exceptions.ValidationError):
create_google_meet_live_class(
batch_name=self.batch.name,
google_meet_account=self.google_meet_settings.name,
title="Test No Calendar",
duration=30,
date=add_days(nowdate(), 1),
time="10:00:00",
timezone="Asia/Kolkata",
)
self.google_meet_settings.google_calendar = old_calendar
self.google_meet_settings.save()
def test_update_live_class_date_updates_event(self):
"""Rescheduling a live class should update the linked Event."""
live_class = self._create_live_class()
live_class.reload()
event_name = live_class.event
new_date = add_days(nowdate(), 5)
live_class.date = new_date
live_class.save(ignore_permissions=True)
event = frappe.get_doc("Event", event_name)
self.assertIn(str(new_date), str(event.starts_on))
def test_update_live_class_time_updates_event(self):
"""Changing the time of a live class should update the linked Event."""
live_class = self._create_live_class()
live_class.reload()
event_name = live_class.event
live_class.time = "15:00:00"
live_class.save(ignore_permissions=True)
event = frappe.get_doc("Event", event_name)
self.assertIn("15:00", str(event.starts_on))
def test_update_live_class_title_updates_event(self):
"""Changing the title of a live class should update the linked Event subject."""
live_class = self._create_live_class()
live_class.reload()
event_name = live_class.event
live_class.title = "Updated Title"
live_class.save(ignore_permissions=True)
event = frappe.get_doc("Event", event_name)
self.assertIn("Updated Title", event.subject)
def test_update_live_class_duration_updates_event(self):
"""Changing the duration should update the linked Event's end time."""
live_class = self._create_live_class()
live_class.reload()
event_name = live_class.event
live_class.duration = 120
live_class.save(ignore_permissions=True)
event = frappe.get_doc("Event", event_name)
self.assertIn("12:00", str(event.ends_on))
def test_delete_live_class_deletes_event(self):
"""Deleting a live class should delete the linked Frappe Event."""
live_class = self._create_live_class()
live_class.reload()
event_name = live_class.event
self.assertTrue(frappe.db.exists("Event", event_name))
# Remove from cleanup since we're deleting manually
self.cleanup_items = [
(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_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"
self.batch.google_meet_account = ""
with self.assertRaises(frappe.exceptions.ValidationError):
self.batch.save()
self.batch.reload()
def test_batch_validation_google_meet_with_valid_account(self):
"""Saving a batch with Google Meet and a valid account should succeed."""
self.batch.conferencing_provider = "Google Meet"
self.batch.google_meet_account = self.google_meet_settings.name
self.batch.save()
self.batch.reload()
self.assertEqual(self.batch.conferencing_provider, "Google Meet")
self.assertEqual(self.batch.google_meet_account, self.google_meet_settings.name)
self.batch.conferencing_provider = ""
self.batch.google_meet_account = ""
self.batch.save()
def test_batch_validation_zoom_without_account(self):
"""Saving a batch with Zoom provider but no account should fail."""
self.batch.conferencing_provider = "Zoom"
self.batch.zoom_account = ""
with self.assertRaises(frappe.exceptions.ValidationError):
self.batch.save()
self.batch.reload()

View File

@@ -101,7 +101,7 @@ def set_total_marks(questions: list) -> int:
@frappe.whitelist()
def quiz_summary(quiz: str, results: str):
def submit_quiz(quiz: str, results: str):
results = results and json.loads(results)
percentage = 0
@@ -128,14 +128,13 @@ def quiz_summary(quiz: str, results: str):
score_out_of = quiz_details.total_marks
percentage = (score / score_out_of) * 100 if score_out_of else 0
submission = create_submission(quiz, results, score_out_of, quiz_details.passing_percentage)
save_progress_after_quiz(quiz_details, percentage)
return {
"score": score,
"score_out_of": score_out_of,
"submission": submission.name,
"pass": percentage == quiz_details.passing_percentage,
"pass": percentage >= quiz_details.passing_percentage,
"percentage": percentage,
"is_open_ended": is_open_ended,
}
@@ -158,21 +157,14 @@ def process_results(results: list, quiz_details: dict):
result["marks_out_of"] = question_details.marks
if question_details.type != "Open Ended":
if len(result["is_correct"]) > 0:
correct = result["is_correct"][0]
for point in result["is_correct"]:
correct = correct and point
result["is_correct"] = correct
else:
result["is_correct"] = 0
correct = verify_answer(question_details.question, result["answer"])
result["answer"] = ", ".join(result["answer"])
if correct:
marks = question_details.marks
result["marks"] = question_details.marks
else:
marks = -quiz_details.marks_to_cut if quiz_details.enable_negative_marking else 0
result["marks"] = -quiz_details.marks_to_cut if quiz_details.enable_negative_marking else 0
result["marks"] = marks
score += marks
score += result["marks"]
else:
is_open_ended = True
@@ -188,6 +180,26 @@ def process_results(results: list, quiz_details: dict):
}
def verify_answer(question: str, answer: list):
question_details = get_question_details(question)
correct = False
if question_details.multiple:
for num in range(1, 5):
if question_details[f"option_{num}"] in answer:
correct = question_details[f"is_correct_{num}"]
if not correct:
return False
if question_details[f"is_correct_{num}"] and question_details[f"option_{num}"] not in answer:
return False
return True
for num in range(1, 5):
if question_details[f"option_{num}"] in answer:
correct = question_details[f"is_correct_{num}"]
return correct
def _save_file(match: re.Match) -> str:
data = match.group(1).split("data:")[1]
headers, content = data.split(",")
@@ -258,22 +270,27 @@ def save_progress_after_quiz(quiz_details: dict, percentage: float):
@frappe.whitelist()
def check_answer(question: str, type: str, answers: str):
answers = json.loads(answers)
if type == "Choices":
def check_answer(question: str, question_type: str, answers: str):
answers = answers and json.loads(answers)
if question_type == "Choices":
return check_choice_answers(question, answers)
else:
return check_input_answers(question, answers[0])
def check_choice_answers(question: str, answers: list):
def get_question_details(question: str):
fields = ["multiple"]
is_correct = []
for num in range(1, 5):
fields.append(f"option_{cstr(num)}")
fields.append(f"is_correct_{cstr(num)}")
question_details = frappe.db.get_value("LMS Question", question, fields, as_dict=1)
return question_details
def check_choice_answers(question: str, answers: list):
is_correct = []
question_details = get_question_details(question)
for num in range(1, 5):
if question_details[f"option_{num}"] in answers:

View File

@@ -19,7 +19,6 @@ from frappe.utils import (
get_datetime,
get_frappe_version,
get_fullname,
get_time_str,
getdate,
nowtime,
pretty_date,
@@ -1128,6 +1127,8 @@ def get_batch_details(batch: str):
"timezone",
"category",
"zoom_account",
"conferencing_provider",
"google_meet_account",
],
as_dict=True,
)
@@ -1138,7 +1139,7 @@ def get_batch_details(batch: str):
if (
not batch_details.accept_enrollments
and batch_details.start_date == getdate()
and get_time_str(batch_details.start_time) > nowtime()
and str(batch_details.start_time) > nowtime()
):
batch_details.accept_enrollments = True
@@ -1174,7 +1175,7 @@ def categorize_batches(batches: list) -> dict:
private.append(batch)
elif getdate(batch.start_date) < getdate():
archived.append(batch)
elif getdate(batch.start_date) == getdate() and get_time_str(batch.start_time) < nowtime():
elif getdate(batch.start_date) == getdate() and str(batch.start_time) < nowtime():
archived.append(batch)
else:
upcoming.append(batch)
@@ -2156,14 +2157,14 @@ def filter_batches_based_on_start_time(batches: list, filters: dict) -> list:
batches_to_remove = [
batch
for batch in batches
if getdate(batch.start_date) == getdate() and get_time_str(batch.start_time) < nowtime()
if getdate(batch.start_date) == getdate() and str(batch.start_time) < nowtime()
]
batches = [batch for batch in batches if batch not in batches_to_remove]
elif batchType == "archived":
batches_to_remove = [
batch
for batch in batches
if getdate(batch.start_date) == getdate() and get_time_str(batch.start_time) >= nowtime()
if getdate(batch.start_date) == getdate() and str(batch.start_time) >= nowtime()
]
batches = [batch for batch in batches if batch not in batches_to_remove]
return batches

View File

@@ -3,7 +3,7 @@ msgstr ""
"Project-Id-Version: frappe\n"
"Report-Msgid-Bugs-To: jannat@frappe.io\n"
"POT-Creation-Date: 2026-01-23 16:05+0000\n"
"PO-Revision-Date: 2026-02-24 17:18\n"
"PO-Revision-Date: 2026-03-03 18:47\n"
"Last-Translator: jannat@frappe.io\n"
"Language-Team: Spanish\n"
"MIME-Version: 1.0\n"
@@ -3307,7 +3307,7 @@ msgstr "Comentarios del instructor"
#. Label of a Link in the LMS Workspace
#: lms/lms/workspace/lms/lms.json
msgid "Interest"
msgstr ""
msgstr "Interés"
#: frontend/src/components/Sidebar/AppSidebar.vue:512
#: frontend/src/components/Sidebar/AppSidebar.vue:515

View File

@@ -3,7 +3,7 @@ msgstr ""
"Project-Id-Version: frappe\n"
"Report-Msgid-Bugs-To: jannat@frappe.io\n"
"POT-Creation-Date: 2026-01-23 16:05+0000\n"
"PO-Revision-Date: 2026-02-05 06:43\n"
"PO-Revision-Date: 2026-03-01 18:33\n"
"Last-Translator: jannat@frappe.io\n"
"Language-Team: Russian\n"
"MIME-Version: 1.0\n"
@@ -3076,7 +3076,7 @@ msgstr "Скрыть мою личную информацию от других"
#: frontend/src/components/Notes/InlineLessonMenu.vue:12
msgid "Highlight"
msgstr ""
msgstr "Выделите"
#. Label of the highlighted_text (Small Text) field in DocType 'LMS Lesson
#. Note'
@@ -4310,7 +4310,7 @@ msgstr "Мета-ключевые слова"
#: frontend/src/pages/BatchForm.vue:244 frontend/src/pages/CourseForm.vue:251
msgid "Meta Tags"
msgstr ""
msgstr "Мета-теги"
#: lms/lms/api.py:1481
msgid "Meta tags should be a list."

View File

@@ -3,7 +3,7 @@ msgstr ""
"Project-Id-Version: frappe\n"
"Report-Msgid-Bugs-To: jannat@frappe.io\n"
"POT-Creation-Date: 2026-01-23 16:05+0000\n"
"PO-Revision-Date: 2026-02-06 06:38\n"
"PO-Revision-Date: 2026-03-03 18:47\n"
"Last-Translator: jannat@frappe.io\n"
"Language-Team: Swedish\n"
"MIME-Version: 1.0\n"
@@ -41,7 +41,7 @@ msgstr " du är på"
#. Paragraph text in the LMS Workspace
#: lms/lms/workspace/lms/lms.json
msgid "<a href=\"/app/lms-settings/LMS%20Settings\">LMS Settings</a>"
msgstr "<a href=\"/app/lms-settings/LMS%20Settings\">LMS Inställningar</a>"
msgstr "<a href=\"/app/lms-settings/LMS%20Settings\">Inställningar</a>"
#. Paragraph text in the LMS Workspace
#: lms/lms/workspace/lms/lms.json
@@ -70,7 +70,7 @@ msgstr "<p>Kära {{ member_name }},</p>\\n\\n<p>Du har blivit inskriven i vår k
#. Header text in the LMS Workspace
#: lms/lms/workspace/lms/lms.json
msgid "<span class=\"h4\"><b>Get Started</b></span>"
msgstr "<span class=\"h4\"><b>Kom Igång</b></span>"
msgstr "<span class=\"h4\"><b>Genvägar</b></span>"
#. Header text in the LMS Workspace
#: lms/lms/workspace/lms/lms.json
@@ -347,12 +347,12 @@ msgstr "Tillåt Gäst Åtkomst"
#. Label of the allow_posting (Check) field in DocType 'Job Settings'
#: lms/job/doctype/job_settings/job_settings.json
msgid "Allow Job Posting From Website"
msgstr "Tillåt jobbannonsering från webbplats"
msgstr "Tillåt Jobb Annonsering från Webbplats"
#. Label of the allow_self_enrollment (Check) field in DocType 'LMS Batch'
#: lms/lms/doctype/lms_batch/lms_batch.json
msgid "Allow Self Enrollment"
msgstr "Tillåt självregistrering"
msgstr "Tillåt Självregistrering"
#. Label of the allow_future (Check) field in DocType 'LMS Batch'
#: lms/lms/doctype/lms_batch/lms_batch.json
@@ -1624,7 +1624,7 @@ msgstr "Kurs Kapitel"
#. Label of a shortcut in the LMS Workspace
#: lms/lms/workspace/lms/lms.json
msgid "Course Completed"
msgstr "Klara Kurser"
msgstr "Klar Kurs"
#: frontend/src/pages/Statistics.vue:31
msgid "Course Completions"
@@ -1668,7 +1668,7 @@ msgstr "Kursbeskrivning"
#: frontend/src/components/Settings/BadgeForm.vue:203
#: frontend/src/components/Settings/Badges.vue:201
msgid "Course Enrollment"
msgstr "Kurs Inskrivning"
msgstr "Kurs Registrering"
#: frontend/src/pages/Statistics.vue:22
msgid "Course Enrollments"
@@ -2465,7 +2465,7 @@ msgstr "Registrering i denna grupp är begränsad. Vänligen kontakta Administra
#: frontend/src/pages/Programs/ProgramProgressSummary.vue:15
#: lms/lms/doctype/lms_course/lms_course.json lms/lms/workspace/lms/lms.json
msgid "Enrollments"
msgstr "Inskrivningar"
msgstr "Registreringar"
#: lms/lms/doctype/lms_settings/lms_settings.py:27
msgid "Enter Client Id and Client Secret in Google Settings to send calendar invites for evaluations."
@@ -2913,7 +2913,7 @@ msgstr "Portal"
#. Label of the general_tab (Tab Break) field in DocType 'LMS Settings'
#: lms/lms/doctype/lms_settings/lms_settings.json
msgid "General"
msgstr "Allmän"
msgstr "Allmänt"
#: frontend/src/components/Modals/BulkCertificates.vue:5
#: frontend/src/pages/Batch.vue:12
@@ -2937,7 +2937,7 @@ msgstr "Bli Certifierad"
#: lms/templates/onboarding_header.html:8
msgid "Get Started"
msgstr "Kom Igång"
msgstr "Genvägar"
#: frontend/src/components/InstallPrompt.vue:9
msgid "Get the app on your device for easy access & a better experience!"
@@ -3589,7 +3589,7 @@ msgstr "Kurs Intresse"
#. Name of a DocType
#: lms/lms/doctype/lms_course_mentor_mapping/lms_course_mentor_mapping.json
msgid "LMS Course Mentor Mapping"
msgstr "LMS Kurs Mentor Tilldelning"
msgstr "Kurs Mentor Tilldelning"
#. Name of a DocType
#: lms/lms/doctype/lms_course_progress/lms_course_progress.json
@@ -3599,12 +3599,12 @@ msgstr "Kurs Framsteg"
#. Name of a DocType
#: lms/lms/doctype/lms_course_review/lms_course_review.json
msgid "LMS Course Review"
msgstr "LMS Kurs Granskning"
msgstr "Kurs Recension"
#. Name of a DocType
#: lms/lms/doctype/lms_enrollment/lms_enrollment.json
msgid "LMS Enrollment"
msgstr "Inskrivning"
msgstr "Registrering"
#. Name of a DocType
#: lms/job/doctype/lms_job_application/lms_job_application.json
@@ -3614,7 +3614,7 @@ msgstr "Jobb Ansökan"
#. Name of a DocType
#: lms/lms/doctype/lms_lesson_note/lms_lesson_note.json
msgid "LMS Lesson Note"
msgstr "Lektionsanteckning"
msgstr "Lektion Anteckning"
#. Name of a DocType
#: lms/lms/doctype/lms_live_class/lms_live_class.json
@@ -3659,12 +3659,12 @@ msgstr "Program Medlem"
#. Name of a DocType
#: lms/lms/doctype/lms_programming_exercise/lms_programming_exercise.json
msgid "LMS Programming Exercise"
msgstr "Programmeringvning"
msgstr "Programmering Övning"
#. Name of a DocType
#: lms/lms/doctype/lms_programming_exercise_submission/lms_programming_exercise_submission.json
msgid "LMS Programming Exercise Submission"
msgstr "Programmeringvning Inlämning"
msgstr "Programmering Övning Inlämning"
#. Name of a DocType
#: lms/lms/doctype/lms_question/lms_question.json
@@ -7081,7 +7081,7 @@ msgstr "Kommande"
#: frontend/src/pages/Batch.vue:191 frontend/src/pages/Home/AdminHome.vue:34
msgid "Upcoming Batches"
msgstr "Kommande grupper"
msgstr "Kommande Grupper"
#: frontend/src/components/UpcomingEvaluations.vue:5
#: frontend/src/pages/Home/AdminHome.vue:92 lms/templates/upcoming_evals.html:3
@@ -7234,7 +7234,7 @@ msgstr "Violett"
#: frontend/src/components/BatchOverlay.vue:73
msgid "Visit Batch"
msgstr "Besök Omgång"
msgstr "Besök Grupp"
#: frontend/src/pages/JobDetail.vue:52
msgid "Visit Website"
@@ -7610,7 +7610,7 @@ msgstr "Din lektion {0} är idag"
#: frontend/src/components/Modals/EmailTemplateModal.vue:35
msgid "Your enrollment in {{ batch_name }} is confirmed"
msgstr "Din inskrivning till {{ batch_name }} är bekräftad"
msgstr "Din registrering till {{ batch_name }} är bekräftad"
#: lms/lms/notification/certificate_request_reminder/certificate_request_reminder.html:3
#: lms/templates/emails/certificate_request_notification.html:3

View File

@@ -119,4 +119,5 @@ lms.patches.v2_0.open_to_work
lms.patches.v2_0.share_enrollment
lms.patches.v2_0.give_user_list_permission #11-02-2026
lms.patches.v2_0.rename_badge_assignment_event
lms.patches.v2_0.enable_allow_job_posting
lms.patches.v2_0.enable_allow_job_posting
lms.patches.v2_0.set_conferencing_provider_for_zoom

View File

@@ -0,0 +1,16 @@
import frappe
def execute():
frappe.db.set_value(
"LMS Batch",
{"zoom_account": ["is", "set"]},
"conferencing_provider",
"Zoom",
)
frappe.db.set_value(
"LMS Live Class",
{"zoom_account": ["is", "set"]},
"conferencing_provider",
"Zoom",
)

View File

@@ -0,0 +1,116 @@
{
"app": "lms",
"creation": "2025-11-24 14:35:18.461657",
"docstatus": 0,
"doctype": "Workspace Sidebar",
"header_icon": "education",
"idx": 0,
"items": [
{
"child": 0,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Home",
"link_to": "Learning",
"link_type": "Workspace",
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Users",
"link_to": "User",
"link_type": "DocType",
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Course",
"link_to": "LMS Course",
"link_type": "DocType",
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Enrollments",
"link_to": "LMS Enrollment",
"link_type": "DocType",
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Batch",
"link_to": "LMS Batch",
"link_type": "DocType",
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Batch Enrollment",
"link_to": "LMS Batch Enrollment",
"link_type": "DocType",
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Evaluation Request",
"link_to": "LMS Certificate Request",
"link_type": "DocType",
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Evaluation",
"link_to": "LMS Certificate Evaluation",
"link_type": "DocType",
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Certificate",
"link_to": "LMS Certificate",
"link_type": "DocType",
"show_arrow": 0,
"type": "Link"
}
],
"modified": "2026-03-02 13:07:37.040316",
"modified_by": "sayali@frappe.io",
"module": "LMS",
"name": "LMS",
"owner": "Administrator",
"standard": 1,
"title": "LMS"
}