mirror of
https://github.com/frappe/lms.git
synced 2026-04-19 22:52:29 +03:00
Merge pull request #2165 from frappe/develop
merge `develop` into `main-hotifx`
This commit is contained in:
2
frontend/components.d.ts
vendored
2
frontend/components.d.ts
vendored
@@ -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']
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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')"
|
||||
|
||||
@@ -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(
|
||||
{},
|
||||
|
||||
@@ -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()
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
<template>
|
||||
<div class="flex flex-col h-full">
|
||||
<div class="flex flex-col h-full text-p-base">
|
||||
<div>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="font-semibold mb-1 text-ink-gray-9">
|
||||
{{ __(label) }}
|
||||
<div class="space-y-2">
|
||||
<div class="font-semibold text-xl text-ink-gray-9">
|
||||
{{ __(label) }}
|
||||
</div>
|
||||
<div class="text-ink-gray-6 leading-5">
|
||||
{{ __(description) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-x-2">
|
||||
<Badge
|
||||
@@ -21,9 +26,6 @@
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-xs text-ink-gray-5">
|
||||
{{ __(description) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="overflow-y-auto">
|
||||
<SettingFields :sections="sections" :data="branding.data" />
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div class="flex min-h-0 flex-col text-base">
|
||||
<div class="flex items-center justify-between mb-5">
|
||||
<div>
|
||||
<div class="text-xl font-semibold mb-1 text-ink-gray-9">
|
||||
<div class="text-xl font-semibold mb-2 text-ink-gray-9">
|
||||
{{ __(label) }}
|
||||
</div>
|
||||
<div class="text-ink-gray-6 leading-5">
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div class="flex min-h-0 flex-col text-base">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="text-xl font-semibold mb-1 text-ink-gray-9">
|
||||
<div class="text-xl font-semibold mb-2 text-ink-gray-9">
|
||||
{{ __(label) }}
|
||||
</div>
|
||||
<div class="text-ink-gray-6 leading-5">
|
||||
@@ -10,7 +10,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex item-center space-x-2">
|
||||
<Button variant="solid" @click="() => (showForm = !showForm)">
|
||||
<Button @click="() => (showForm = !showForm)">
|
||||
<template #prefix>
|
||||
<Plus class="size-4 stroke-1.5" />
|
||||
</template>
|
||||
|
||||
197
frontend/src/components/Settings/GoogleMeetAccountModal.vue
Normal file
197
frontend/src/components/Settings/GoogleMeetAccountModal.vue
Normal 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>
|
||||
202
frontend/src/components/Settings/GoogleMeetSettings.vue
Normal file
202
frontend/src/components/Settings/GoogleMeetSettings.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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[]) => {
|
||||
|
||||
@@ -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 [
|
||||
{
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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.'
|
||||
),
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
]
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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()"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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')
|
||||
}}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -15,17 +15,22 @@
|
||||
</Button>
|
||||
</header>
|
||||
<div class="py-5">
|
||||
<div class="w-5/6 mx-auto">
|
||||
<div class="grid grid-cols-2 gap-5 w-5/6 mx-auto">
|
||||
<FormControl
|
||||
v-model="lesson.title"
|
||||
label="Title"
|
||||
:label="__('Title')"
|
||||
class="mb-4"
|
||||
:required="true"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<FormControl
|
||||
<Switch
|
||||
v-model="lesson.include_in_preview"
|
||||
type="checkbox"
|
||||
label="Include in Preview"
|
||||
:label="__('Include in Preview')"
|
||||
:description="
|
||||
__(
|
||||
'If enabled, the lesson will also be accessible to users who are not enrolled in the course.'
|
||||
)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<div class="border-t mt-4">
|
||||
@@ -83,6 +88,7 @@ import {
|
||||
Button,
|
||||
createResource,
|
||||
FormControl,
|
||||
Switch,
|
||||
usePageMeta,
|
||||
toast,
|
||||
} from 'frappe-ui'
|
||||
@@ -708,8 +714,8 @@ iframe {
|
||||
height: 15px;
|
||||
}
|
||||
|
||||
.ce-popover--opened > .ce-popover__container {
|
||||
max-height: unset;
|
||||
.ce-popover--opened {
|
||||
max-height: unset !important;
|
||||
}
|
||||
|
||||
.cdx-search-field__icon svg {
|
||||
|
||||
@@ -126,6 +126,7 @@ export function getEditorTools() {
|
||||
defaultStyle: 'ordered',
|
||||
},
|
||||
},
|
||||
upload: Upload,
|
||||
table: {
|
||||
class: Table,
|
||||
inlineToolbar: true,
|
||||
@@ -133,7 +134,6 @@ export function getEditorTools() {
|
||||
quiz: Quiz,
|
||||
assignment: Assignment,
|
||||
program: Program,
|
||||
upload: Upload,
|
||||
markdown: {
|
||||
class: Markdown,
|
||||
inlineToolbar: true,
|
||||
@@ -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) => {
|
||||
'"': '"',
|
||||
"'": ''',
|
||||
'`': '`',
|
||||
'=': '=',
|
||||
}
|
||||
|
||||
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'],
|
||||
})
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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) {
|
||||
|
||||
// },
|
||||
// });
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
|
||||
@@ -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"],
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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 "Programmeringsövning"
|
||||
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 "Programmeringsövning 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
|
||||
|
||||
@@ -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
|
||||
16
lms/patches/v2_0/set_conferencing_provider_for_zoom.py
Normal file
16
lms/patches/v2_0/set_conferencing_provider_for_zoom.py
Normal 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",
|
||||
)
|
||||
116
lms/workspace_sidebar/lms.json
Normal file
116
lms/workspace_sidebar/lms.json
Normal 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"
|
||||
}
|
||||
Reference in New Issue
Block a user