Merge pull request #2181 from frappe/main-hotfix

chore: merge 'main-hotfix' into 'main'
This commit is contained in:
Jannat Patel
2026-03-11 12:12:16 +05:30
committed by GitHub
97 changed files with 4326 additions and 1545 deletions
+1
View File
@@ -11,6 +11,7 @@ cd ./frappe-bench || exit
bench -v setup requirements
echo "Setting Up LMS App..."
bench get-app "https://github.com/frappe/payments"
bench get-app lms "${GITHUB_WORKSPACE}"
echo "Setting Up Sites & Database..."
+3
View File
@@ -62,6 +62,9 @@ jobs:
mkdir -p ~/bench-cache
(cd && tar czf ~/bench-cache/bench.tgz frappe-bench)
fi
- name: add payments app to bench
working-directory: /home/runner/frappe-bench
run: bench get-app https://github.com/frappe/payments
- name: add lms app to bench
working-directory: /home/runner/frappe-bench
run: bench get-app lms $GITHUB_WORKSPACE
+12 -11
View File
@@ -104,16 +104,18 @@ describe("Course Creation", () => {
cy.closeOnboardingModal();
cy.url().should("include", "/lms/courses");
cy.get(".grid a:first").within(() => {
cy.get("div").contains("Test Course");
cy.get("div").contains(
"Test Course Short Introduction to test the UI"
);
cy.get(".bg-cover")
.invoke("css", "background-image")
.should("include", "/files/profile");
});
cy.get(".grid a:first").click();
cy.get("div")
.contains("Test Course")
.closest("a")
.within(() => {
cy.get("div").contains(
"Test Course Short Introduction to test the UI"
);
cy.get(".bg-cover")
.invoke("css", "background-image")
.should("include", "/files/profile");
});
cy.get("div").contains("Test Course").closest("a").click();
cy.url().should("include", "/lms/courses/test-course");
cy.get("div").contains("Test Course");
cy.get("div").contains("Test Course Short Introduction to test the UI");
@@ -142,7 +144,6 @@ describe("Course Creation", () => {
);
// Add Discussion
cy.get("span").contains("Community").click();
cy.button("New Question").click();
cy.wait(500);
cy.get("[data-dismissable-layer]").within(() => {
+2
View File
@@ -60,6 +60,8 @@ declare module 'vue' {
ExplanationVideos: typeof import('./src/components/Modals/ExplanationVideos.vue')['default']
FeedbackModal: typeof import('./src/components/Modals/FeedbackModal.vue')['default']
FrappeCloudIcon: typeof import('./src/components/Icons/FrappeCloudIcon.vue')['default']
GoogleMeetAccountModal: typeof import('./src/components/Settings/GoogleMeetAccountModal.vue')['default']
GoogleMeetSettings: typeof import('./src/components/Settings/GoogleMeetSettings.vue')['default']
IconPicker: typeof import('./src/components/Controls/IconPicker.vue')['default']
IndicatorIcon: typeof import('./src/components/Icons/IndicatorIcon.vue')['default']
InlineLessonMenu: typeof import('./src/components/Notes/InlineLessonMenu.vue')['default']
+1 -3
View File
@@ -27,13 +27,11 @@
"@editorjs/table": "2.4.2",
"@vueuse/core": "^14.1.0",
"ace-builds": "1.36.2",
"apexcharts": "4.3.0",
"chart.js": "4.4.1",
"codemirror": "6.0.1",
"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",
+1 -1
View File
@@ -1,6 +1,6 @@
<template>
<FrappeUIProvider>
<Layout class="isolate text-base">
<Layout class="isolate text-p-base">
<router-view />
</Layout>
<InstallPrompt v-if="isMobile && !settings.data?.disable_pwa" />
+13 -6
View File
@@ -83,7 +83,8 @@
private: true,
}"
:validateFile="
(file) => validateFile(file, assignment.data.type.toLowerCase())
(file) =>
validateFile(file, true, assignment.data.type.toLowerCase())
"
@success="(file) => saveSubmission(file)"
>
@@ -418,11 +419,17 @@ 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 (props.submissionName == 'new') {
return true
} else if (
submissionResource.doc?.owner == user.data?.name &&
submissionResource.doc?.status == 'Not Graded'
) {
return true
}
return false
})
const submissionStatusOptions = computed(() => {
+13 -11
View File
@@ -10,7 +10,7 @@
{{ course.data.price }}
</div>
<div v-if="!readOnlyMode">
<div v-if="course.data.membership" class="space-y-2">
<div v-if="course.data.membership" class="space-y-2 mb-8">
<router-link
:to="{
name: 'Lesson',
@@ -46,7 +46,7 @@
},
}"
>
<Button variant="solid" size="md" class="w-full">
<Button variant="solid" size="md" class="w-full mb-8">
<template #prefix>
<CreditCard class="size-4 stroke-1.5" />
</template>
@@ -67,7 +67,7 @@
v-else-if="!isAdmin"
@click="enrollStudent()"
variant="solid"
class="w-full"
class="w-full mb-8"
size="md"
>
<template #prefix>
@@ -90,24 +90,26 @@
{{ __('Get Certificate') }}
</Button>
</div>
<div class="space-y-4">
<div
class="font-medium text-ink-gray-9"
:class="{ 'mt-8': !readOnlyMode }"
>
<div class="space-y-3">
<div class="font-medium text-ink-gray-9">
{{ __('This course has:') }}
</div>
<div class="flex items-center text-ink-gray-9">
<BookOpen class="h-4 w-4 stroke-1.5" />
<span class="ml-2">
{{ course.data.lessons }} {{ __('Lessons') }}
{{ course.data.lessons }}
{{ course.data.lessons > 1 ? __('lessons') : __('lesson') }}
</span>
</div>
<div class="flex items-center text-ink-gray-9">
<Users class="h-4 w-4 stroke-1.5" />
<span class="ml-2">
{{ formatAmount(course.data.enrollments) }}
{{ __('Enrolled Students') }}
{{
course.data.enrollments > 1
? __('enrolled students')
: __('enrolled student')
}}
</span>
</div>
<div
@@ -116,7 +118,7 @@
>
<Star class="size-4 stroke-1.5 fill-yellow-500 text-transparent" />
<span class="ml-2">
{{ course.data.rating }} {{ __('Rating') }}
{{ course.data.rating }} {{ __('average rating') }}
</span>
</div>
<div
+1 -3
View File
@@ -4,9 +4,7 @@
<div class="text-lg font-semibold text-ink-gray-7 mb-2.5">
{{ __('No {0}').format(type?.toLowerCase()) }}
</div>
<div
class="leading-5 text-base w-full md:w-2/5 text-base text-center text-ink-gray-7"
>
<div class="text-p-base w-full md:w-2/5 text-center text-ink-gray-7">
{{
__(
'There are no {0} currently. Keep an eye out, fresh learning experiences are on the way!'
+2 -2
View File
@@ -3,13 +3,13 @@
<div class="space-y-2">
<div class="flex items-center text-sm font-medium space-x-2">
<span>
{{ __('What does include in preview mean?') }}
{{ __('What are Instructor Notes?') }}
</span>
</div>
<div class="text-xs text-ink-gray-5 mb-1 leading-5">
{{
__(
'If Include in Preview is enabled for a lesson then the lesson will also be accessible to non logged in users.'
'Instructor Notes are private notes that only instructors can see. They can be used to provide additional context or guidance for the lesson.'
)
}}
</div>
@@ -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)
@@ -14,7 +14,7 @@
: __('Edit Assignment')
}}
</div>
<div class="space-y-4 max-h-[75vh] overflow-y-auto">
<div class="space-y-4 max-h-[75vh] overflow-y-auto p-1">
<FormControl
v-model="assignment.title"
:label="__('Title')"
@@ -43,7 +43,7 @@
@change="(val) => (assignment.question = 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-[7rem] max-h-[18rem] overflow-y-auto"
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-[18rem] overflow-y-auto"
/>
</div>
</div>
@@ -73,7 +73,7 @@
import { Button, Dialog, FormControl, TextEditor, toast } from 'frappe-ui'
import { computed, reactive, watch } from 'vue'
import { escapeHTML, sanitizeHTML } from '@/utils'
import { Link } from 'frappe-ui/frappe'
import Link from '@/components/Controls/Link.vue'
const show = defineModel()
const assignments = defineModel<Assignments>('assignments')
@@ -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()
},
+9 -12
View File
@@ -2,7 +2,7 @@
<Dialog
v-model="show"
:options="{
size: '5xl',
size: '3xl',
}"
>
<template #body>
@@ -10,17 +10,14 @@
<div class="text-lg font-semibold text-ink-gray-9 mb-5">
{{ __(props.title) }}
</div>
<div
<Switch
v-if="!editMode"
class="flex items-center text-xs text-ink-gray-7 space-x-5"
>
<Switch
size="sm"
:label="__('Choose an existing question')"
v-model="chooseFromExisting"
class="!p-0"
/>
</div>
size="sm"
:label="__('Choose an existing question')"
:description="__('Select from questions you have already created')"
v-model="chooseFromExisting"
class="!p-0"
/>
<div v-if="!chooseFromExisting || editMode">
<div>
<label class="block text-xs text-ink-gray-5 mb-1">
@@ -164,7 +161,7 @@ populateFields()
const props = defineProps({
title: {
type: String,
default: __('Add a new question'),
default: __('Add new question'),
},
questionDetail: {
type: [Object, null],
@@ -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
+3 -6
View File
@@ -465,7 +465,7 @@ watch(
)
const quizSubmission = createResource({
url: 'lms.lms.doctype.lms_quiz.lms_quiz.quiz_summary',
url: 'lms.lms.doctype.lms_quiz.lms_quiz.submit_quiz',
makeParams(values) {
return {
quiz: quiz.data.name,
@@ -538,7 +538,7 @@ const checkAnswer = () => {
url: 'lms.lms.doctype.lms_quiz.lms_quiz.check_answer',
params: {
question: currentQuestion.value,
type: questionDetails.data.type,
question_type: questionDetails.data.type,
answers: JSON.stringify(answers),
},
auto: true,
@@ -569,10 +569,7 @@ const addToLocalStorage = () => {
let quizData = JSON.parse(localStorage.getItem(quiz.data.title))
let questionData = {
question_name: currentQuestion.value,
answer: getAnswers().join(),
is_correct: showAnswers.filter((answer) => {
return answer != undefined
}),
answer: getAnswers(),
}
if (quizData) {
@@ -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>
@@ -31,7 +31,7 @@
<Search class="size-4 stroke-1.5 text-ink-gray-5" />
</template>
</FormControl>
<div class="overflow-auto h-[60vh]">
<div class="overflow-auto max-h-[60vh]">
<div class="divide-y divide-outline-gray-modals">
<div
v-for="evaluator in evaluators.data"
@@ -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>
@@ -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>
+3 -3
View File
@@ -2,7 +2,7 @@
<div class="flex min-h-0 flex-col text-base">
<div class="flex items-center justify-between">
<div>
<div class="text-xl font-semibold mb-1 text-ink-gray-9">
<div class="text-xl font-semibold mb-2 text-ink-gray-9">
{{ __(label) }}
</div>
<div class="text-ink-gray-6 leading-5">
@@ -10,7 +10,7 @@
</div>
</div>
<div class="flex item-center space-x-2">
<Button variant="solid" @click="() => (showForm = !showForm)">
<Button @click="() => (showForm = !showForm)">
<template #prefix>
<Plus class="size-4 stroke-1.5" />
</template>
@@ -31,7 +31,7 @@
<Search class="size-4 stroke-1.5 text-ink-gray-5" />
</template>
</FormControl>
<div class="overflow-y-scroll h-[60vh]">
<div class="overflow-y-scroll max-h-[60vh]">
<ul class="divide-y divide-outline-gray-modals">
<li
v-for="member in memberList"
@@ -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>
+48 -28
View File
@@ -76,6 +76,7 @@ import PaymentGateways from '@/components/Settings/PaymentGateways.vue'
import Coupons from '@/components/Settings/Coupons/Coupons.vue'
import Transactions from '@/components/Settings/Transactions/Transactions.vue'
import ZoomSettings from '@/components/Settings/ZoomSettings.vue'
import GoogleMeetSettings from '@/components/Settings/GoogleMeetSettings.vue'
import Badges from '@/components/Settings/Badges.vue'
const show = defineModel()
@@ -268,34 +269,6 @@ const tabsStructure = computed(() => {
},
],
},
],
},
{
label: 'Lists',
hideLabel: false,
items: [
{
label: 'Members',
description:
'Add new members or manage roles and permissions of existing members',
icon: 'UserRoundPlus',
template: markRaw(Members),
},
{
label: 'Evaluators',
description: '',
icon: 'UserCheck',
description:
'Add new evaluators or check the slots existing evaluators',
template: markRaw(Evaluators),
},
{
label: 'Zoom Accounts',
description:
'Manage zoom accounts to conduct live classes from batches',
icon: 'Video',
template: markRaw(ZoomSettings),
},
{
label: 'Badges',
description:
@@ -317,6 +290,27 @@ const tabsStructure = computed(() => {
},
],
},
{
label: 'Users',
hideLabel: false,
items: [
{
label: 'Members',
description:
'Add new members or manage roles and permissions of existing members',
icon: 'User',
template: markRaw(Members),
},
{
label: 'Evaluators',
description: '',
icon: 'UserCircle2',
description:
'Add new evaluators or check the slots of existing evaluators',
template: markRaw(Evaluators),
},
],
},
{
label: 'Payment',
hideLabel: false,
@@ -387,6 +381,26 @@ const tabsStructure = computed(() => {
},
],
},
{
label: 'Conferencing',
hideLabel: false,
items: [
{
label: 'Zoom',
description:
'Manage zoom accounts to conduct live classes from batches',
icon: 'Video',
template: markRaw(ZoomSettings),
},
{
label: 'Google Meet',
description:
'Manage Google Meet accounts to conduct live classes from batches',
icon: 'Presentation',
template: markRaw(GoogleMeetSettings),
},
],
},
{
label: 'Customize',
hideLabel: false,
@@ -394,6 +408,8 @@ const tabsStructure = computed(() => {
{
label: 'Branding',
icon: 'Blocks',
description:
'Customize the brand name and logo to make the application your own',
template: markRaw(BrandSettings),
sections: [
{
@@ -482,6 +498,8 @@ const tabsStructure = computed(() => {
{
label: 'Signup',
icon: 'LogIn',
description:
'Manage the settings related to user signup and registration',
sections: [
{
columns: [
@@ -517,6 +535,8 @@ const tabsStructure = computed(() => {
{
label: 'SEO',
icon: 'Search',
description:
'Manage the SEO settings to improve your website ranking on search engines',
sections: [
{
columns: [
@@ -55,17 +55,18 @@
:label="__('Member')"
doctype="User"
v-model="transactionData.member"
:required="true"
:required="!!fieldMeta.member?.reqd"
/>
<FormControl
:label="__('Billing Name')"
v-model="transactionData.billing_name"
:required="true"
:required="!!fieldMeta.billing_name?.reqd"
/>
<Link
:label="__('Source')"
v-model="transactionData.source"
doctype="LMS Source"
:required="!!fieldMeta.source?.reqd"
/>
<FormControl
type="select"
@@ -73,12 +74,14 @@
:label="__('Payment For Document Type')"
v-model="transactionData.payment_for_document_type"
doctype="DocType"
:required="!!fieldMeta.payment_for_document_type?.reqd"
/>
<Link
v-if="transactionData.payment_for_document_type"
:label="__('Payment For Document')"
v-model="transactionData.payment_for_document"
:doctype="transactionData.payment_for_document_type"
:required="!!fieldMeta.payment_for_document?.reqd"
/>
</div>
@@ -90,17 +93,18 @@
:label="__('Currency')"
v-model="transactionData.currency"
doctype="Currency"
:required="true"
:required="!!fieldMeta.currency?.reqd"
/>
<FormControl
:label="__('Amount')"
v-model="transactionData.amount"
:required="true"
:required="!!fieldMeta.amount?.reqd"
/>
<FormControl
v-if="transactionData.amount_with_gst"
:label="__('Amount with GST')"
v-model="transactionData.amount_with_gst"
:required="!!fieldMeta.amount_with_gst?.reqd"
/>
</div>
@@ -113,21 +117,25 @@
v-if="transactionData.coupon"
:label="__('Coupon Code')"
v-model="transactionData.coupon"
:required="!!fieldMeta.coupon?.reqd"
/>
<FormControl
v-if="transactionData.coupon"
:label="__('Coupon Code')"
v-model="transactionData.coupon_code"
:required="!!fieldMeta.coupon_code?.reqd"
/>
<FormControl
v-if="transactionData.coupon"
:label="__('Discount Amount')"
v-model="transactionData.discount_amount"
:required="!!fieldMeta.discount_amount?.reqd"
/>
<FormControl
v-if="transactionData.coupon"
:label="__('Original Amount')"
v-model="transactionData.original_amount"
:required="!!fieldMeta.original_amount?.reqd"
/>
</div>
</div>
@@ -140,17 +148,27 @@
:label="__('Address')"
v-model="transactionData.address"
doctype="Address"
:required="true"
:required="!!fieldMeta.address?.reqd"
/>
<FormControl
:label="__('GSTIN')"
v-model="transactionData.gstin"
:required="!!fieldMeta.gstin?.reqd"
/>
<FormControl
:label="__('PAN')"
v-model="transactionData.pan"
:required="!!fieldMeta.pan?.reqd"
/>
<FormControl :label="__('GSTIN')" v-model="transactionData.gstin" />
<FormControl :label="__('PAN')" v-model="transactionData.pan" />
<FormControl
:label="__('Payment ID')"
v-model="transactionData.payment_id"
:required="!!fieldMeta.payment_id?.reqd"
/>
<FormControl
:label="__('Order ID')"
v-model="transactionData.order_id"
:required="!!fieldMeta.order_id?.reqd"
/>
</div>
</div>
@@ -171,6 +189,10 @@ const show = defineModel('show')
const props = defineProps<{
transactions: any
data: any
fieldMeta: Record<
string,
{ reqd?: number; default?: string; description?: string }
>
}>()
const saveTransaction = () => {
@@ -211,48 +233,49 @@ const updateTransaction = () => {
}
const openDetails = () => {
if (props.data) {
const docType = props.data.payment_for_document_type
const docName = props.data.payment_for_document
if (docType && docName) {
router.push({
name: docType == 'LMS Course' ? 'CourseDetail' : 'BatchDetail',
params: {
[docType == 'LMS Course' ? 'courseName' : 'batchName']: docName,
},
})
}
const docType = transactionData.value?.payment_for_document_type
const docName = transactionData.value?.payment_for_document
if (docType && docName) {
router.push({
name: docType == 'LMS Course' ? 'CourseDetail' : 'BatchDetail',
params: {
[docType == 'LMS Course' ? 'courseName' : 'batchName']: docName,
},
})
show.value = false
}
}
const emptyTransactionData = {
const getDefault = (fieldname: string) =>
props.fieldMeta[fieldname]?.default || null
const getEmptyTransactionData = () => ({
payment_received: false,
payment_for_certificate: false,
member: null,
billing_name: null,
source: null,
payment_for_document_type: null,
payment_for_document: null,
member: getDefault('member'),
billing_name: getDefault('billing_name'),
source: getDefault('source'),
payment_for_document_type: getDefault('payment_for_document_type'),
payment_for_document: getDefault('payment_for_document'),
member_consent: false,
currency: null,
amount: null,
amount_with_gst: null,
coupon: null,
coupon_code: null,
discount_amount: null,
original_amount: null,
order_id: null,
payment_id: null,
gstin: null,
pan: null,
address: null,
}
currency: getDefault('currency'),
amount: getDefault('amount'),
amount_with_gst: getDefault('amount_with_gst'),
coupon: getDefault('coupon'),
coupon_code: getDefault('coupon_code'),
discount_amount: getDefault('discount_amount'),
original_amount: getDefault('original_amount'),
order_id: getDefault('order_id'),
payment_id: getDefault('payment_id'),
gstin: getDefault('gstin'),
pan: getDefault('pan'),
address: getDefault('address'),
})
watch(
() => props.data,
(newVal) => {
transactionData.value = newVal ? { ...newVal } : emptyTransactionData
transactionData.value = newVal ? { ...newVal } : getEmptyTransactionData()
},
{ immediate: true }
)
@@ -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">
@@ -3,6 +3,7 @@
v-if="step == 'new'"
:transactions="transactions"
:data="data"
:fieldMeta="fieldMeta.data || {}"
v-model:show="show"
@updateStep="updateStep"
/>
@@ -17,13 +18,14 @@
v-else-if="step == 'details'"
:transactions="transactions"
:data="data"
:fieldMeta="fieldMeta.data || {}"
v-model:show="show"
@updateStep="updateStep"
/>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { createListResource } from 'frappe-ui'
import { createListResource, createResource } from 'frappe-ui'
import TransactionList from '@/components/Settings/Transactions/TransactionList.vue'
import TransactionDetails from '@/components/Settings/Transactions/TransactionDetails.vue'
@@ -45,6 +47,11 @@ const updateStep = (newStep: 'list' | 'new' | 'edit', newData: any) => {
}
}
const fieldMeta = createResource({
url: 'lms.lms.api.get_payment_field_meta',
auto: true,
})
const transactions = createListResource({
doctype: 'LMS Payment',
fields: [
@@ -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(() => {
+66 -1
View File
@@ -40,7 +40,7 @@
class="flex items-center text-sm text-ink-gray-5 my-1"
>
<span class="grid h-5 w-6 flex-shrink-0 place-items-center">
<ChevronRight
<ChevronsRight
class="h-4 w-4 stroke-1.5 text-ink-gray-9 transition-all duration-300 ease-in-out"
:class="{ 'rotate-90': !sidebarStore.isWebpagesCollapsed }"
/>
@@ -90,6 +90,56 @@
)
}}
</div>
<div
v-if="
isStudent && !profileIsComplete && !sidebarStore.isSidebarCollapsed
"
class="flex flex-col gap-3 text-ink-gray-9 py-2.5 px-3 bg-surface-white shadow-sm rounded-md"
>
<div class="flex flex-col text-p-sm gap-1">
<div class="inline-flex gap-1">
<User class="h-4 my-0.5 shrink-0" />
<div class="font-medium">
{{ __('Complete your profile') }}
</div>
</div>
<div class="text-ink-gray-7 leading-5">
{{ __('Highlight what makes you unique and show your skills.') }}
</div>
</div>
<router-link
:to="{
name: 'Profile',
params: {
username: userResource.data?.username,
},
}"
>
<Button :label="__('My Profile')" class="w-full">
<template #prefix>
<ChevronsRight class="h-4 w-4 text-ink-gray-7 stroke-1.5" />
</template>
</Button>
</router-link>
</div>
<Tooltip
v-if="
isStudent && !profileIsComplete && sidebarStore.isSidebarCollapsed
"
:text="__('Complete your profile')"
>
<router-link
:to="{
name: 'Profile',
params: {
username: userResource.data?.username,
},
}"
class="flex items-center justify-center"
>
<User class="size-4 stroke-1.5 text-ink-gray-7 cursor-pointer" />
</router-link>
</Tooltip>
<TrialBanner
v-if="
userResource.data?.is_system_manager && userResource.data?.is_fc_site
@@ -210,15 +260,18 @@ import {
markRaw,
h,
onUnmounted,
computed,
} from 'vue'
import {
BookOpen,
CircleAlert,
ChevronRight,
ChevronsRight,
Plus,
CircleHelp,
FolderTree,
FileText,
User,
UserPlus,
Users,
BookText,
@@ -613,6 +666,18 @@ const redirectToWebsite = () => {
window.open('https://frappe.io/learning', '_blank')
}
const isStudent = computed(() => {
return userResource.data?.is_student
})
const profileIsComplete = computed(() => {
return (
userResource.data?.user_image &&
userResource.data?.headline &&
userResource.data?.bio
)
})
onUnmounted(() => {
socket.off('publish_lms_notifications')
})
+1 -1
View File
@@ -48,7 +48,7 @@ const apps = createResource({
name: 'frappe',
logo: '/assets/lms/images/desk.png',
title: __('Desk'),
route: '/desk/lms',
route: '/desk/learning',
},
]
data.map((app) => {
@@ -65,7 +65,7 @@
<script setup>
import { sessionStore } from '@/stores/session'
import { Dropdown } from 'frappe-ui'
import { call, Dropdown, toast } from 'frappe-ui'
import { useRouter } from 'vue-router'
import { convertToTitleCase } from '@/utils'
import { usersStore } from '@/stores/user'
@@ -85,7 +85,7 @@ import {
User,
Settings,
Sun,
Zap,
Trash2,
} from 'lucide-vue-next'
const router = useRouter()
@@ -175,6 +175,19 @@ const userDropdownOptions = computed(() => {
return userResource.data?.is_moderator
},
},
{
label: 'Clear Demo Data',
icon: Trash2,
onClick: () => {
clearDemoDataConfirmation()
},
condition: () => {
return (
userResource.data?.is_moderator &&
settingsStore.settings.data?.demo_data_present
)
},
},
{
icon: FrappeCloudIcon,
label: 'Login to Frappe Cloud',
@@ -234,4 +247,36 @@ const loginToFrappeCloud = () => {
let redirect_to = '/dashboard/sites/' + userResource.data.sitename
window.open(`${frappeCloudBaseEndpoint}${redirect_to}`, '_blank')
}
const clearDemoDataConfirmation = () => {
$dialog({
title: __('Confirm clearing demo data?'),
message: __(
'Are you sure you want to clear the demo data? This would delete the course "A guide to Frappe Learning" along with all its associated data. This action cannot be undone.'
),
actions: [
{
label: __('Confirm'),
theme: 'red',
variant: 'solid',
onClick(close) {
clearDemoData()
close()
},
},
],
})
}
const clearDemoData = () => {
call('lms.lms.api.clear_demo_data')
.then(() => {
window.location.href = '/lms'
toast.success(__('Demo data cleared successfully'))
})
.catch((error) => {
toast.error(__(error.message || 'Error clearing demo data'))
console.error('Error clearing demo data:', error)
})
}
</script>
+25 -44
View File
@@ -41,55 +41,36 @@
<span class="font-semibold text-ink-gray-9 leading-5">
{{ evl.course_title }}
</span>
<Menu
<Dropdown
v-if="evl.date > dayjs().format()"
as="div"
class="relative inline-block text-left"
:options="[
{
label: __('Cancel'),
icon: Ban,
onClick() {
cancelEvaluation(evl)
},
},
]"
placement="left"
side="left"
>
<div>
<MenuButton class="inline-flex w-full justify-center">
<EllipsisVertical class="w-4 h-4 stroke-1.5" />
</MenuButton>
</div>
<transition
enter-active-class="transition duration-100 ease-out"
enter-from-class="transform scale-95 opacity-0"
enter-to-class="transform scale-100 opacity-100"
leave-active-class="transition duration-75 ease-in"
leave-from-class="transform scale-100 opacity-100"
leave-to-class="transform scale-95 opacity-0"
>
<MenuItems
class="absolute mt-2 w-32 rounded-md bg-surface-white border p-1.5"
>
<MenuItem v-slot="{ active }">
<Button
variant="ghost"
class="w-full"
@click="cancelEvaluation(evl)"
>
<template #prefix>
<Ban
:active="active"
class="size-4 stroke-1.5"
aria-hidden="true"
/>
</template>
{{ __('Cancel') }}
</Button>
</MenuItem>
</MenuItems>
</transition>
</Menu>
<template v-slot="{ open }">
<Button variant="ghost">
<template #icon>
<EllipsisVertical class="w-4 h-4 stroke-1.5" />
</template>
</Button>
</template>
</Dropdown>
</div>
<div class="flex items-center mb-2">
<div class="flex items-center mb-3">
<Calendar class="w-4 h-4 stroke-1.5" />
<span class="ml-2">
{{ dayjs(evl.date).format('DD MMMM YYYY') }}
</span>
</div>
<div class="flex items-center mb-2">
<div class="flex items-center mb-3">
<Clock class="w-4 h-4 stroke-1.5" />
<span class="ml-2">
{{ formatTime(evl.start_time) }}
@@ -139,9 +120,8 @@ import {
} from 'lucide-vue-next'
import { inject, ref, getCurrentInstance, computed } from 'vue'
import { formatTime } from '@/utils'
import { Button, createListResource, call, toast } from 'frappe-ui'
import { Button, createListResource, call, Dropdown, toast } from 'frappe-ui'
import EvaluationModal from '@/components/Modals/EvaluationModal.vue'
import { Menu, MenuButton, MenuItems, MenuItem } from '@headlessui/vue'
const dayjs = inject('$dayjs')
const user = inject('$user')
@@ -186,6 +166,7 @@ const upcoming_evals = createListResource({
'evaluator_name',
'course_title',
'member',
'member_name',
'google_meet_link',
],
orderBy: 'date',
@@ -220,7 +201,7 @@ const endDateHasPassed = computed(() => {
const cancelEvaluation = (evl) => {
$dialog({
title: __('Cancel this evaluation?'),
title: __('Confirm Cancellation?'),
message: __(
'Are you sure you want to cancel this evaluation? This action cannot be undone.'
),
+5 -2
View File
@@ -18,7 +18,7 @@
</Button>
</div>
<Dropdown
v-else-if="isAdmin"
v-else-if="isAdmin && batchMenu.length"
:options="batchMenu"
placement="left"
side="left"
@@ -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(() => {
@@ -209,6 +209,9 @@ const canMakeAnnouncement = () => {
}
const batchMenu = computed(() => {
if (!batch.data?.certification && !canMakeAnnouncement()) {
return []
}
let options = [
{
label: __('Generate Certificates'),
+61 -20
View File
@@ -165,24 +165,48 @@
"
/>
</div>
<div class="space-y-5">
<Link
doctype="LMS Zoom Settings"
:label="__('Zoom Account')"
v-model="batchDetail.doc.zoom_account"
:onCreate="
(value, close) => {
openSettings('Zoom Accounts', close)
}
"
/>
<Uploader
v-model="batchDetail.doc.video_link"
:label="__('Preview Video')"
type="video"
:required="false"
/>
</div>
<Uploader
v-model="batchDetail.doc.video_link"
:label="__('Preview Video')"
type="video"
:required="false"
/>
</div>
</div>
<div class="px-5 pb-5 space-y-5 border-b mb-5">
<div class="text-lg text-ink-gray-9 font-semibold mb-4">
{{ __('Conferencing') }}
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
<FormControl
v-model="batchDetail.doc.conferencing_provider"
type="select"
:options="conferencingOptions"
:label="__('Conferencing Provider')"
/>
<Link
v-if="batchDetail.doc.conferencing_provider === 'Zoom'"
doctype="LMS Zoom Settings"
:label="__('Zoom Account')"
v-model="batchDetail.doc.zoom_account"
:onCreate="
(value, close) => {
openSettings('Zoom Accounts', close)
}
"
/>
<Link
v-if="batchDetail.doc.conferencing_provider === 'Google Meet'"
doctype="LMS Google Meet Settings"
:label="__('Google Meet Account')"
v-model="batchDetail.doc.google_meet_account"
:onCreate="
(value, close) => {
openSettings('Google Meet Accounts', close)
}
"
/>
</div>
</div>
@@ -463,14 +487,31 @@ const trashBatch = (close) => {
})
}
const conferencingOptions = computed(() => {
return [
{
label: '',
value: '',
},
{
label: __('Zoom'),
value: 'Zoom',
},
{
label: __('Google Meet'),
value: 'Google Meet',
},
]
})
const mediumOptions = computed(() => {
return [
{
label: 'Online',
label: __('Online'),
value: 'Online',
},
{
label: 'Offline',
label: __('Offline'),
value: 'Offline',
},
]
@@ -56,7 +56,7 @@
</span>
</div>
<div v-if="!readOnlyMode">
<div v-if="!readOnlyMode && !canAccessBatch">
<router-link
:to="{
name: 'Billing',
@@ -71,7 +71,7 @@
batch.data.accept_enrollments
"
>
<Button v-if="!canAccessBatch" class="w-full mt-4" variant="solid">
<Button class="w-full mt-4" variant="solid">
<template #prefix>
<CreditCard class="size-4 stroke-1.5" />
</template>
@@ -151,6 +151,10 @@ const enrollInBatch = () => {
},
})
},
onError(err) {
toast.error(__(err.messages?.[0] || err))
console.error(err)
},
}
)
}
@@ -169,14 +173,6 @@ const isEvaluator = computed(() => {
return user.data?.is_evaluator
})
const isInstructor = computed(() => {
return (
props.batch.data?.instructors?.filter(
(instructor) => instructor.name === user.data?.name
).length > 0
)
})
const canAccessBatch = computed(() => {
if (!user.data) {
return false
@@ -184,7 +180,7 @@ const canAccessBatch = computed(() => {
return isModerator.value || isStudent.value || isEvaluator.value
})
const canEditBatch = computed(() => {
return isModerator.value || isInstructor.value
const isAdmin = computed(() => {
return isModerator.value || isEvaluator.value
})
</script>
@@ -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,88 @@
>
<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"
autocomplete="off"
/>
<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"
autocomplete="off"
/>
<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">
<FormControl
v-model="batch.description"
:label="__('Description')"
type="textarea"
:required="true"
:rows="4"
/>
<MultiSelect
v-model="batch.instructors"
doctype="Course Evaluator"
:label="__('Instructors')"
:required="true"
:onCreate="(close: () => void) => openSettings('Evaluators', close)"
:filters="{ ignore_user_type: 1 }"
/>
</div>
<div class="">
<div class="mb-1.5 text-sm text-ink-gray-5">
{{ __('Batch Details') }}
@@ -93,7 +100,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 +118,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 +134,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 +161,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 +236,17 @@ onBeforeUnmount(() => {
data: batch.value,
})
})
const mediumOptions = computed(() => {
return [
{
label: __('Online'),
value: 'Online',
},
{
label: __('Offline'),
value: 'Offline',
},
]
})
</script>
+35 -21
View File
@@ -114,25 +114,27 @@
<FormControl
:label="__('Billing Name')"
v-model="billingDetails.billing_name"
:required="true"
:required="!!fieldMeta.billing_name?.reqd"
/>
<FormControl
:label="__('Address Line 1')"
v-model="billingDetails.address_line1"
:required="true"
:required="!!fieldMeta.address_line1?.reqd"
/>
<FormControl
:label="__('Address Line 2')"
v-model="billingDetails.address_line2"
:required="!!fieldMeta.address_line2?.reqd"
/>
<FormControl
:label="__('City')"
v-model="billingDetails.city"
:required="true"
:required="!!fieldMeta.city?.reqd"
/>
<FormControl
:label="__('State/Province')"
v-model="billingDetails.state"
:required="!!fieldMeta.state?.reqd"
/>
</div>
<div class="space-y-4">
@@ -141,34 +143,36 @@
:value="billingDetails.country"
@change="(option) => changeCurrency(option)"
:label="__('Country')"
:required="true"
:required="!!fieldMeta.country?.reqd"
/>
<FormControl
:label="__('Postal Code')"
v-model="billingDetails.pincode"
:required="true"
:required="!!fieldMeta.pincode?.reqd"
/>
<FormControl
:label="__('Phone Number')"
v-model="billingDetails.phone"
:required="true"
:required="!!fieldMeta.phone?.reqd"
/>
<Link
doctype="LMS Source"
:value="billingDetails.source"
@change="(option) => (billingDetails.source = option)"
:label="__('Where did you hear about us?')"
:required="true"
:required="!!fieldMeta.source?.reqd"
/>
<FormControl
v-if="billingDetails.country == 'India'"
:label="__('GST Number')"
v-model="billingDetails.gstin"
:required="!!fieldMeta.gstin?.reqd"
/>
<FormControl
v-if="billingDetails.country == 'India'"
:label="__('PAN Number')"
v-model="billingDetails.pan"
:required="!!fieldMeta.pan?.reqd"
/>
</div>
</div>
@@ -273,6 +277,7 @@ const access = createResource({
name: props.name,
},
onSuccess(data) {
Object.assign(fieldMeta, data.billing_field_meta || {})
setBillingDetails(data.address)
orderSummary.submit()
},
@@ -295,19 +300,24 @@ const orderSummary = createResource({
const appliedCoupon = ref(null)
const billingDetails = reactive({})
const fieldMeta = reactive({})
const getDefault = (fieldname) => fieldMeta[fieldname]?.default || ''
const setBillingDetails = (data) => {
billingDetails.billing_name = data?.billing_name || ''
billingDetails.address_line1 = data?.address_line1 || ''
billingDetails.address_line2 = data?.address_line2 || ''
billingDetails.city = data?.city || ''
billingDetails.state = data?.state || ''
billingDetails.country = data?.country || ''
billingDetails.pincode = data?.pincode || ''
billingDetails.phone = data?.phone || ''
billingDetails.source = data?.source || ''
billingDetails.gstin = data?.gstin || ''
billingDetails.pan = data?.pan || ''
billingDetails.billing_name = data?.billing_name || getDefault('billing_name')
billingDetails.address_line1 =
data?.address_line1 || getDefault('address_line1')
billingDetails.address_line2 =
data?.address_line2 || getDefault('address_line2')
billingDetails.city = data?.city || getDefault('city')
billingDetails.state = data?.state || getDefault('state')
billingDetails.country = data?.country || getDefault('country')
billingDetails.pincode = data?.pincode || getDefault('pincode')
billingDetails.phone = data?.phone || getDefault('phone')
billingDetails.source = data?.source || getDefault('source')
billingDetails.gstin = data?.gstin || getDefault('gstin')
billingDetails.pan = data?.pan || getDefault('pan')
}
const paymentLink = createResource({
@@ -336,7 +346,7 @@ const generatePaymentLink = () => {
{},
{
validate() {
if (!billingDetails.source) {
if (!billingDetails.source && fieldMeta.source?.reqd) {
return __('Please let us know where you heard about us from.')
}
if (!billingDetails.member_consent) {
@@ -370,15 +380,19 @@ function removeCoupon() {
}
const validateAddress = () => {
let mandatoryFields = [
let billingFields = [
'billing_name',
'address_line1',
'address_line2',
'city',
'state',
'pincode',
'country',
'phone',
'source',
'gstin',
'pan',
]
let mandatoryFields = billingFields.filter((f) => fieldMeta[f]?.reqd)
for (let field of mandatoryFields) {
if (!billingDetails[field])
return (
@@ -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"
+10 -3
View File
@@ -1,7 +1,10 @@
<template>
<div class="pl-5">
<div class="grid grid-cols-1 md:grid-cols-[70%,30%]">
<div v-if="courseResource.doc" class="lg:max-h-[88vh] lg:overflow-y-auto">
<div
v-if="courseResource.doc"
class="lg:max-h-[88vh] lg:overflow-y-auto px-1"
>
<div class="my-5">
<div class="pr-5 md:pr-10 pb-5 mb-5 space-y-5 border-b">
<div class="text-lg font-semibold mb-4 text-ink-gray-9">
@@ -71,7 +74,11 @@
<ColorSwatches
v-model="courseResource.doc.card_gradient"
:label="__('Color')"
:description="__('Choose a color for the course card')"
:description="
__(
'Select a fallback color for the course card when no image is set.'
)
"
class="w-full"
@update:modelValue="makeFormDirty()"
/>
@@ -164,7 +171,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()"
-21
View File
@@ -187,26 +187,6 @@ const setCategories = (data) => {
}
}
const isPersonaCaptured = async () => {
let persona = await call('frappe.client.get_single_value', {
doctype: 'LMS Settings',
field: 'persona_captured',
})
return persona
}
const identifyUserPersona = async () => {
if (user.data?.is_system_manager && !user.data?.developer_mode) {
let personaCaptured = await isPersonaCaptured()
if (personaCaptured) return
if (!courseCount.value) {
router.push({
name: 'PersonaForm',
})
}
}
}
const getCourseCount = () => {
if (!user.data) return
if (!user.data.is_moderator) return
@@ -214,7 +194,6 @@ const getCourseCount = () => {
doctype: 'LMS Course',
}).then((data) => {
courseCount.value = data
identifyUserPersona()
})
}
+36 -6
View File
@@ -13,6 +13,7 @@
v-model="course.title"
:label="__('Title')"
:required="true"
autocomplete="off"
/>
<Link
doctype="LMS Category"
@@ -57,7 +58,7 @@
@change="(val: string) => (course.description = val)"
:editable="true"
:fixedMenu="true"
editorClass="prose-sm max-w-none border-b border-x border-outline-gray-modals bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[10rem]"
editorClass="prose-sm max-w-none border-b border-x border-outline-gray-modals bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[10rem] max-h-[17rem] overflow-auto"
/>
</div>
</div>
@@ -77,7 +78,7 @@ import { Button, Dialog, FormControl, TextEditor, toast } from 'frappe-ui'
import { useOnboarding, useTelemetry } from 'frappe-ui/frappe'
import { inject, onMounted, onBeforeUnmount, ref } from 'vue'
import { useRouter } from 'vue-router'
import { cleanError, openSettings } from '@/utils'
import { cleanError, openSettings, sanitizeHTML, escapeHTML } from '@/utils'
import Link from '@/components/Controls/Link.vue'
import MultiSelect from '@/components/Controls/MultiSelect.vue'
import Uploader from '@/components/Controls/Uploader.vue'
@@ -87,12 +88,22 @@ const router = useRouter()
const { capture } = useTelemetry()
const { updateOnboardingStep } = useOnboarding('learning')
const user = inject<any>('$user')
const courseCreated = ref(false)
const props = defineProps<{
courses: any
}>()
const course = ref({
type Course = {
title: string
short_introduction: string
description: string
instructors: string[]
category: string | null
image: string | null
}
const course = ref<Course>({
title: '',
short_introduction: '',
description: '',
@@ -101,7 +112,23 @@ const course = ref({
image: null,
})
const validateFields = () => {
course.value.description = sanitizeHTML(course.value.description)
Object.keys(course.value).forEach((key) => {
if (
key != 'description' &&
typeof course.value[key as keyof Course] === 'string'
) {
course.value[key as keyof Course] = escapeHTML(
course.value[key as keyof Course] as string
)
}
})
}
const saveCourse = (close: () => void = () => {}) => {
validateFields()
props.courses.insert.submit(
{
...course.value,
@@ -114,6 +141,7 @@ const saveCourse = (close: () => void = () => {}) => {
toast.success(__('Course created successfully'))
close()
capture('course_created')
courseCreated.value = true
router.push({
name: 'CourseDetail',
params: { courseName: data.name },
@@ -153,8 +181,10 @@ onMounted(() => {
onBeforeUnmount(() => {
window.removeEventListener('keydown', keyboardShortcut)
capture('course_form_closed', {
data: course.value,
})
if (!courseCreated.value) {
capture('course_form_closed', {
data: course.value,
})
}
})
</script>
@@ -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>
+107 -107
View File
@@ -1,5 +1,112 @@
<template>
<div>
<div class="mt-10 space-y-10">
<div v-if="evals?.data?.length">
<div class="font-semibold text-lg text-ink-gray-9 mb-3">
{{ __('Upcoming Evaluations') }}
</div>
<div class="grid grid-cols-1 md:grid-cols-4 gap-5">
<div
v-for="evaluation in evals?.data"
class="border hover:border-outline-gray-3 rounded-md p-3 flex flex-col h-full cursor-pointer"
@click="redirectToProfile()"
>
<div class="font-semibold text-ink-gray-9 text-lg leading-5 mb-3">
{{ evaluation.course_title }}
</div>
<div class="text-ink-gray-7">
<div class="flex items-center mb-3">
<Calendar class="w-4 h-4 stroke-1.5" />
<span class="ml-2">
{{ dayjs(evaluation.date).format('DD MMMM YYYY') }}
</span>
</div>
<div class="flex items-center mb-3">
<Clock class="w-4 h-4 stroke-1.5" />
<span class="ml-2">
{{ formatTime(evaluation.start_time) }}
</span>
</div>
<div class="flex items-center">
<GraduationCap class="w-4 h-4 stroke-1.5" />
<span class="ml-2">
{{ evaluation.member_name }}
</span>
</div>
</div>
</div>
</div>
</div>
<div v-if="liveClasses?.data?.length">
<div class="font-semibold text-lg text-ink-gray-9 mb-3">
{{ __('Upcoming Live Classes') }}
</div>
<div class="grid grid-cols-1 md:grid-cols-4 gap-5">
<div
v-for="cls in liveClasses?.data"
class="border hover:border-outline-gray-3 rounded-md p-3"
>
<div class="font-semibold text-ink-gray-9 text-lg leading-5 mb-1">
{{ cls.title }}
</div>
<div class="text-ink-gray-7 leading-5 mb-4">
{{ cls.description }}
</div>
<div class="mt-auto space-y-3 text-ink-gray-7">
<div class="flex items-center space-x-2">
<Calendar class="w-4 h-4 stroke-1.5" />
<span>
{{ dayjs(cls.date).format('DD MMMM YYYY') }}
</span>
</div>
<div class="flex items-center space-x-2">
<Clock class="w-4 h-4 stroke-1.5" />
<span>
{{ formatTime(cls.time) }} -
{{ dayjs(getClassEnd(cls)).format('HH:mm A') }}
</span>
</div>
<div
v-if="canAccessClass(cls)"
class="flex items-center space-x-2 text-ink-gray-9 mt-auto"
>
<a
v-if="user.data?.is_moderator || user.data?.is_evaluator"
:href="cls.start_url"
target="_blank"
class="cursor-pointer inline-flex items-center justify-center gap-2 transition-colors focus:outline-none text-ink-gray-8 bg-surface-gray-2 hover:bg-surface-gray-3 active:bg-surface-gray-4 focus-visible:ring focus-visible:ring-outline-gray-3 h-7 text-base px-2 rounded"
:class="cls.join_url ? 'w-full' : 'w-1/2'"
>
<Monitor class="h-4 w-4 stroke-1.5" />
{{ __('Start') }}
</a>
<a
:href="cls.join_url"
target="_blank"
class="w-full cursor-pointer inline-flex items-center justify-center gap-2 transition-colors focus:outline-none text-ink-gray-8 bg-surface-gray-2 hover:bg-surface-gray-3 active:bg-surface-gray-4 focus-visible:ring focus-visible:ring-outline-gray-3 h-7 text-base px-2 rounded"
>
<Video class="h-4 w-4 stroke-1.5" />
{{ __('Join') }}
</a>
</div>
<Tooltip
v-else-if="hasClassEnded(cls)"
:text="__('This class has ended')"
placement="right"
>
<div class="flex items-center space-x-2 text-ink-amber-3 w-fit">
<Info class="w-4 h-4 stroke-1.5" />
<span>
{{ __('Ended') }}
</span>
</div>
</Tooltip>
</div>
</div>
</div>
</div>
</div>
<div v-if="createdCourses.data?.length" class="mt-10">
<div class="flex items-center justify-between mb-3">
<span class="font-semibold text-lg text-ink-gray-9">
@@ -85,113 +192,6 @@
</Button>
</router-link>
</div>
<div class="grid grid-cols-2 gap-5 mt-10">
<div v-if="evals?.data?.length">
<div class="font-semibold text-lg text-ink-gray-9 mb-3">
{{ __('Upcoming Evaluations') }}
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-5">
<div
v-for="evaluation in evals?.data"
class="border hover:border-outline-gray-3 rounded-md p-3 flex flex-col h-full cursor-pointer"
@click="redirectToProfile()"
>
<div class="font-semibold text-ink-gray-9 text-lg leading-5 mb-1">
{{ evaluation.course_title }}
</div>
<div class="text-ink-gray-7 text-sm">
<div class="flex items-center mb-2">
<Calendar class="w-4 h-4 stroke-1.5" />
<span class="ml-2">
{{ dayjs(evaluation.date).format('DD MMMM YYYY') }}
</span>
</div>
<div class="flex items-center mb-2">
<Clock class="w-4 h-4 stroke-1.5" />
<span class="ml-2">
{{ formatTime(evaluation.start_time) }}
</span>
</div>
<div class="flex items-center">
<GraduationCap class="w-4 h-4 stroke-1.5" />
<span class="ml-2">
{{ evaluation.member_name }}
</span>
</div>
</div>
</div>
</div>
</div>
<div v-if="liveClasses?.data?.length">
<div class="font-semibold text-lg text-ink-gray-9 mb-3">
{{ __('Upcoming Live Classes') }}
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-5">
<div
v-for="cls in liveClasses?.data"
class="border hover:border-outline-gray-3 rounded-md p-3"
>
<div class="font-semibold text-ink-gray-9 text-lg leading-5 mb-1">
{{ cls.title }}
</div>
<div class="text-ink-gray-7 text-sm leading-5 mb-4">
{{ cls.description }}
</div>
<div class="mt-auto space-y-3 text-ink-gray-7 text-sm">
<div class="flex items-center space-x-2">
<Calendar class="w-4 h-4 stroke-1.5" />
<span>
{{ dayjs(cls.date).format('DD MMMM YYYY') }}
</span>
</div>
<div class="flex items-center space-x-2">
<Clock class="w-4 h-4 stroke-1.5" />
<span>
{{ formatTime(cls.time) }} -
{{ dayjs(getClassEnd(cls)).format('HH:mm A') }}
</span>
</div>
<div
v-if="canAccessClass(cls)"
class="flex items-center space-x-2 text-ink-gray-9 mt-auto"
>
<a
v-if="user.data?.is_moderator || user.data?.is_evaluator"
:href="cls.start_url"
target="_blank"
class="cursor-pointer inline-flex items-center justify-center gap-2 transition-colors focus:outline-none text-ink-gray-8 bg-surface-gray-2 hover:bg-surface-gray-3 active:bg-surface-gray-4 focus-visible:ring focus-visible:ring-outline-gray-3 h-7 text-base px-2 rounded"
:class="cls.join_url ? 'w-full' : 'w-1/2'"
>
<Monitor class="h-4 w-4 stroke-1.5" />
{{ __('Start') }}
</a>
<a
:href="cls.join_url"
target="_blank"
class="w-full cursor-pointer inline-flex items-center justify-center gap-2 transition-colors focus:outline-none text-ink-gray-8 bg-surface-gray-2 hover:bg-surface-gray-3 active:bg-surface-gray-4 focus-visible:ring focus-visible:ring-outline-gray-3 h-7 text-base px-2 rounded"
>
<Video class="h-4 w-4 stroke-1.5" />
{{ __('Join') }}
</a>
</div>
<Tooltip
v-else-if="hasClassEnded(cls)"
:text="__('This class has ended')"
placement="right"
>
<div class="flex items-center space-x-2 text-ink-amber-3 w-fit">
<Info class="w-4 h-4 stroke-1.5" />
<span>
{{ __('Ended') }}
</span>
</div>
</Tooltip>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
+27
View File
@@ -40,12 +40,14 @@
import { computed, inject, onMounted, ref } from 'vue'
import { call, createResource, usePageMeta } from 'frappe-ui'
import { sessionStore } from '@/stores/session'
import { useRouter } from 'vue-router'
import StudentHome from '@/pages/Home/StudentHome.vue'
import AdminHome from '@/pages/Home/AdminHome.vue'
import Streak from '@/pages/Home/Streak.vue'
const user = inject<any>('$user')
const { brand } = sessionStore()
const router = useRouter()
const evalCount = ref(0)
const currentTab = ref<'student' | 'instructor'>('student')
const showStreakModal = ref(false)
@@ -71,7 +73,32 @@ const isAdmin = computed(() => {
)
})
const isPersonaCaptured = async () => {
let persona = await call('frappe.client.get_single_value', {
doctype: 'LMS Settings',
field: 'persona_captured',
})
return persona
}
const identifyUserPersona = async () => {
if (user.data?.is_system_manager && !user.data?.developer_mode) {
let personaCaptured = await isPersonaCaptured()
if (personaCaptured) return
let courseCount = await call('frappe.client.get_count', {
doctype: 'LMS Course',
filters: {
title: ['not like', '%A guide to Frappe Learning%'],
},
})
if (!courseCount) {
router.push({ name: 'PersonaForm' })
}
}
}
onMounted(() => {
identifyUserPersona()
if (isAdmin.value) {
currentTab.value = 'instructor'
} else {
+1 -1
View File
@@ -108,7 +108,7 @@
<div class="flex items-center justify-between mb-3">
<span class="font-semibold text-lg text-ink-gray-9">
{{
myBatches.data?.[0].students.includes(user.data?.name)
myBatches.data?.[0].students?.includes(user.data?.name)
? __('My Batches')
: __('Our Upcoming Batches')
}}
+19 -24
View File
@@ -65,7 +65,7 @@
</router-link>
</div>
</header>
<div class="grid md:grid-cols-[70%,30%] h-screen">
<div class="grid md:grid-cols-[70%,30%] h-[94vh]">
<div v-if="lesson.data.no_preview" class="border-r">
<div class="shadow rounded-md w-3/4 mt-10 mx-auto text-center p-4">
<div class="flex items-center justify-center mt-4 space-x-2">
@@ -263,7 +263,7 @@
</div>
</div>
<div
v-if="lesson.data"
v-if="lesson.data && (allowDiscussions || tabs.length > 1)"
class="mt-10 pb-20 pt-5 border-t px-5"
ref="discussionsContainer"
>
@@ -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>
@@ -399,15 +399,10 @@ const { brand } = sessionStore()
const sidebarStore = useSidebar()
const plyrSources = ref([])
const showInlineMenu = ref(false)
const currentTab = ref('Notes')
const currentTab = ref(null)
let timerInterval = null
const tabs = ref([
{
label: __('Notes'),
value: 'Notes',
},
])
const tabs = ref([])
const props = defineProps({
courseName: {
@@ -887,24 +882,24 @@ const updateNotes = () => {
}
watch(allowDiscussions, () => {
if (allowDiscussions.value) {
tabs.value = [
{
if (!isAdmin.value) {
if (!tabs.value.find((tab) => tab.value === 'Notes')) {
tabs.value.push({
label: __('Notes'),
value: 'Notes',
},
{
})
}
currentTab.value = 'Notes'
} else {
currentTab.value = allowDiscussions.value ? 'Community' : null
}
if (allowDiscussions.value) {
if (!tabs.value.find((tab) => tab.value === 'Community')) {
tabs.value.push({
label: __('Community'),
value: 'Community',
},
]
} else {
tabs.value = [
{
label: __('Notes'),
value: 'Notes',
},
]
})
}
}
})
+13 -7
View File
@@ -15,17 +15,22 @@
</Button>
</header>
<div class="py-5">
<div class="w-5/6 mx-auto">
<div class="grid grid-cols-2 gap-5 w-5/6 mx-auto">
<FormControl
v-model="lesson.title"
label="Title"
:label="__('Title')"
class="mb-4"
:required="true"
autocomplete="off"
/>
<FormControl
<Switch
v-model="lesson.include_in_preview"
type="checkbox"
label="Include in Preview"
:label="__('Include in Preview')"
:description="
__(
'If enabled, the lesson will also be accessible to users who are not enrolled in the course.'
)
"
/>
</div>
<div class="border-t mt-4">
@@ -83,6 +88,7 @@ import {
Button,
createResource,
FormControl,
Switch,
usePageMeta,
toast,
} from 'frappe-ui'
@@ -708,8 +714,8 @@ iframe {
height: 15px;
}
.ce-popover--opened > .ce-popover__container {
max-height: unset;
.ce-popover--opened {
max-height: unset !important;
}
.cdx-search-field__icon svg {
+3 -3
View File
@@ -79,7 +79,7 @@ const submitPersona = () => {
responses: JSON.stringify(responses),
}).then(() => {
router.push({
name: 'Courses',
name: 'Home',
})
})
}
@@ -87,12 +87,12 @@ const submitPersona = () => {
const skipPersonaForm = () => {
call('frappe.client.set_value', {
doctype: 'LMS Settings',
name: null,
name: 'LMS Settings',
fieldname: 'persona_captured',
value: 1,
}).then(() => {
router.push({
name: 'Courses',
name: 'Home',
})
})
}
+5
View File
@@ -199,11 +199,16 @@ const evaluator = createResource({
if (data.slots.unavailable_from) from.value = data.slots.unavailable_from
if (data.slots.unavailable_to) to.value = data.slots.unavailable_to
},
onError(err) {
toast.error(err.messages?.[0] || err)
console.error(err)
},
})
const createSlot = createResource({
url: 'frappe.client.insert',
makeParams(values) {
console.log(evaluator.data)
return {
doc: {
doctype: 'Evaluator Schedule',
@@ -51,7 +51,7 @@
@change="(val: string) => (exercise.problem_statement = 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-[7rem] max-h-[21rem] overflow-y-auto"
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-[21rem] overflow-y-auto"
/>
</div>
</div>
@@ -71,6 +71,7 @@
{{ __('Delete') }}
</Button>
<router-link
v-if="exerciseID != 'new'"
:to="{
name: 'ProgrammingExerciseSubmission',
params: {
@@ -87,6 +88,7 @@
</Button>
</router-link>
<router-link
v-if="exerciseID != 'new'"
:to="{
name: 'ProgrammingExerciseSubmissions',
query: {
@@ -148,6 +150,7 @@ const languageOptions = [
const props = withDefaults(
defineProps<{
exerciseID: string
getExerciseCount: () => Promise<number>
}>(),
{
exerciseID: 'new',
@@ -185,7 +188,6 @@ const setExerciseData = () => {
const testCases = createListResource({
doctype: 'LMS Test Case',
fields: ['input', 'expected_output', 'name'],
cache: ['testCases', props.exerciseID],
parent: 'LMS Programming Exercise',
orderBy: 'idx',
onSuccess(data: TestCase[]) {
@@ -207,7 +209,7 @@ const fetchTestCases = () => {
},
})
testCases.reload()
originalTestCaseCount.value = testCases.data.length
originalTestCaseCount.value = testCases.data?.length
}
const validateTitle = () => {
@@ -223,7 +225,7 @@ watch(
)
watch(testCases, () => {
if (testCases.data.length !== originalTestCaseCount.value) {
if (testCases.data?.length !== originalTestCaseCount.value) {
isDirty.value = true
}
})
@@ -255,6 +257,7 @@ const createNewExercise = (close: () => void) => {
close()
isDirty.value = false
exercises.value?.reload()
props.getExerciseCount()
toast.success(__('Programming Exercise created successfully'))
},
onError(err: any) {
@@ -300,7 +300,7 @@ const loadFalcon = () => {
}
return new Promise((resolve, reject) => {
const script = document.createElement('script')
script.src = `${falconURL.value}static/livecode.js`
script.src = `${falconURL.value}/static/livecode.js`
script.onload = resolve
script.onerror = reject
document.head.appendChild(script)
@@ -5,6 +5,7 @@
<Breadcrumbs :items="breadcrumbs" />
<div class="space-x-2">
<router-link
v-if="exercises.data?.length"
:to="{
name: 'ProgrammingExerciseSubmissions',
}"
@@ -120,8 +121,9 @@
</div>
<ProgrammingExerciseForm
v-model="showForm"
:exerciseID="exerciseID"
v-model:exercises="exercises"
:exerciseID="exerciseID"
:getExerciseCount="getExerciseCount"
/>
</template>
<script setup lang="ts">
@@ -152,7 +154,7 @@ const exerciseCount = ref<number>(0)
const readOnlyMode = window.read_only_mode
const { brand } = sessionStore()
const showForm = ref<boolean>(false)
const exerciseID = ref<string | null>('new')
const exerciseID = ref<string>('new')
const user = inject<any>('$user')
const titleFilter = ref<string>('')
const languageFilter = ref<string>('')
+1 -5
View File
@@ -194,11 +194,7 @@
v-model="showQuestionModal"
:questionDetail="currentQuestion"
v-model:quiz="quizDetails"
:title="
currentQuestion.question
? __('Edit the question')
: __('Add a new question')
"
:title="currentQuestion.question ? __('Edit Question') : __('Add Question')"
/>
</template>
<script setup>
+3 -6
View File
@@ -12,12 +12,8 @@
</header>
<div class="py-5 mx-5">
<div class="flex items-center justify-between mb-4">
<div class="text-lg font-semibold text-ink-gray-7">
{{
quizzes.data?.length
? __('{0} Quizzes').format(quizzes.data.length)
: __('No Quizzes')
}}
<div class="text-lg font-semibold">
{{ __('{0} Quizzes').format(quizzes.data.length) }}
</div>
<FormControl v-model="search" type="text" placeholder="Search">
<template #prefix>
@@ -116,6 +112,7 @@
v-model="title"
:label="__('Title')"
type="text"
autocomplete="off"
@keydown.enter="insertQuiz(() => (showForm = false))"
/>
</template>
+23 -12
View File
@@ -126,6 +126,7 @@ export function getEditorTools() {
defaultStyle: 'ordered',
},
},
upload: Upload,
table: {
class: Table,
inlineToolbar: true,
@@ -133,7 +134,6 @@ export function getEditorTools() {
quiz: Quiz,
assignment: Assignment,
program: Program,
upload: Upload,
markdown: {
class: Markdown,
inlineToolbar: true,
@@ -650,21 +650,19 @@ export const validateFile = async (
console.error(msg)
return msg
}
if (!file.type.startsWith(`${fileType}/`)) {
return error(__('Only {0} file is allowed.').format(fileType))
}
if (fileType == 'pdf' && extension !== 'pdf') {
if (fileType == 'pdf' && extension != 'pdf') {
return error(__('Only PDF files are allowed.'))
}
if (fileType == 'document' && !['doc', 'docx'].includes(extension)) {
} else if (fileType == 'document' && !['doc', 'docx'].includes(extension)) {
return error(
__('Only document file of type .doc or .docx are allowed.')
)
}
if (file.type === 'image/svg+xml') {
} else if (
['image', 'video'].includes(fileType) &&
!file.type.startsWith(`${fileType}/`)
) {
return error(__('Only {0} file is allowed.').format(fileType))
} else if (file.type === 'image/svg+xml') {
const text = await file.text()
const blacklist = [
@@ -696,7 +694,6 @@ export const escapeHTML = (text) => {
'"': '&quot;',
"'": '&#39;',
'`': '&#x60;',
'=': '&#x3D;',
}
return String(text).replace(
@@ -709,6 +706,19 @@ export const sanitizeHTML = (text) => {
text = DOMPurify.sanitize(decodeEntities(text), {
ALLOWED_TAGS: [
'b',
'br',
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
'table',
'thead',
'tbody',
'tr',
'th',
'td',
'i',
'em',
'strong',
@@ -719,6 +729,7 @@ export const sanitizeHTML = (text) => {
'ol',
'li',
'img',
'blockquote',
],
ALLOWED_ATTR: ['href', 'target', 'src'],
})
+548
View File
@@ -0,0 +1,548 @@
import json
import frappe
from lms.lms.doctype.lms_course.lms_course import update_course_statistics
from lms.lms.utils import get_course_progress
def create_demo_data(args: dict = None):
course = create_course()
student = create_user("Ashley", "Ippolito", "ash@ipp.com", "/assets/lms/images/student.jpg")
student1 = create_user("John", "Doe", "john.doe@example.com", "/assets/lms/images/student1.jpeg")
student2 = create_user("Jane", "Smith", "jane.smith@example.com", "/assets/lms/images/student2.jpeg")
create_chapter(course)
create_lessons(course)
enroll_student_in_course(student, course)
enroll_student_in_course(student1, course)
enroll_student_in_course(student2, course)
create_reviews(course, student)
create_progress(course, student, 3)
create_progress(course, student1, 2)
create_progress(course, student2, 4)
frappe.db.set_single_value("LMS Settings", "demo_data_present", 1)
def create_course():
title = "A guide to Frappe Learning"
filters = {"title": title}
if frappe.db.exists("LMS Course", filters):
return frappe.get_doc("LMS Course", filters)
instructor = create_instructor()
course = frappe.new_doc("LMS Course")
course.update(
{
"title": title,
"category": "Business",
"tags": "Frappe, Demo",
"published": 1,
"published_on": frappe.utils.now(),
"video_link": "VIt_bsbBjLI",
"instructors": [{"instructor": instructor.name}],
"short_introduction": "Learn the basics of Frappe Learning and how to get started with your very first course.",
"image": "/assets/lms/images/course_card.jpeg",
}
)
course.description = """
This course will cover the fundamentals of Frappe Learning, including how to create and manage courses, enroll students, and track progress. You will learn about the following key features of the app:
<br>
<h3>Key Features</h3>
<br>
1. Structured Learning: Design a course with a 3-level hierarchy, where your courses have chapters, and you can group your lessons within these chapters. This ensures that the context of each lesson is clearly defined by its chapter.
<br>
<br>
2. Live Classes: Group learners into batches based on courses and duration. You can then create Zoom live classes for these batches directly from the app. Learners can view all the live classes they need to attend as part of their batch.
<br>
<br>
3. Quizzes and Assignments: Create quizzes with single-choice, multiple-choice, or open-ended questions. Instructors can also add assignments that learners can submit as PDFs or documents.
<br>
<br>
4. Getting Certified: Once a learner completes the course or batch, you can grant them a certificate. The app provides an inbuilt certificate template that you can use as-is or customize by creating your own template.
<br>
<br>
To know more about the app and its features, <a href="https://docs.frappe.io/learning">check out the documentation</a>.
"""
course.save()
return course
def create_instructor():
if (
frappe.db.count(
"User",
{
"name": ["not in", ("Administrator", "Guest")],
},
)
> 0
):
user = frappe.get_all(
"User",
{
"name": ["not in", ("Administrator", "Guest")],
},
pluck="name",
limit=1,
)[0]
return frappe.get_doc("User", user)
return create_user("Jannat", "Patel", "jannat@example.com", "/assets/lms/images/instructor.png")
def create_user(first_name, last_name, email, user_image):
filters = {"first_name": first_name, "last_name": last_name, "email": email}
if frappe.db.exists("User", filters):
return frappe.get_doc("User", filters)
user = frappe.new_doc("User")
user.first_name = first_name
user.last_name = last_name
user.user_image = user_image
user.email = email
user.save()
return user
def create_chapter(course):
prepare_chapter(course, "Introduction")
prepare_chapter(course, "Adding content to your lessons")
prepare_chapter(course, "Assessments")
def prepare_chapter(course, chapter_title):
chapter_exists = check_if_chapter_exists(course, chapter_title)
if chapter_exists:
return frappe.get_doc("Course Chapter", chapter_exists)
chapter1 = frappe.new_doc("Course Chapter")
chapter1.course = course.name
chapter1.title = chapter_title
chapter1.save()
add_chapter_to_course(course, chapter1)
def check_if_chapter_exists(course, chapter_title):
filters = {"course": course.name, "title": chapter_title}
return frappe.db.exists("Course Chapter", filters)
def add_chapter_to_course(course, chapter):
course.reload()
course.append("chapters", {"chapter": chapter.name})
course.save()
def create_lessons(course):
create_intro_lesson_1(course)
create_intro_lesson_2(course)
create_content_lesson_1(course)
create_content_lesson_2(course)
create_assessment_lesson_1(course)
def get_chapter(course, chapter_title):
filters = {"course": course.name, "title": chapter_title}
return frappe.get_doc("Course Chapter", filters)
def create_lesson(course, chapter, title, content):
filters = {"course": course.name, "chapter": chapter.name, "title": title}
if frappe.db.exists("Course Lesson", filters):
return frappe.get_doc("Course Lesson", filters)
lesson = frappe.new_doc("Course Lesson")
lesson.course = course.name
lesson.chapter = chapter.name
lesson.title = title
lesson.content = content
lesson.save()
add_lesson_to_chapter(chapter, lesson)
def add_lesson_to_chapter(chapter, lesson):
chapter.reload()
chapter.append("lessons", {"lesson": lesson.name})
chapter.save()
def create_intro_lesson_1(course):
title = "What is a Learning Management System?"
chapter = get_chapter(course, "Introduction")
content = """
{"time":1772449622100,"blocks":[{"id":"vYTdcXYVgI","type":"embed","data":{"service":"youtube","source":"https://www.youtube.com/watch?v=-Ulzqjj49lk","embed":"-Ulzqjj49lk","caption":""}}],"version":"2.29.0"}
"""
create_lesson(course, chapter, title, content)
def create_intro_lesson_2(course):
title = "What is Frappe Learning?"
chapter = get_chapter(course, "Introduction")
content = """
{"time":1772449622100,"blocks":[{"id":"vYTdcXYVgI","type":"embed","data":{"service":"youtube","source":"https://www.youtube.com/watch?v=VIt_bsbBjLI","embed":"VIt_bsbBjLI","caption":""}}],"version":"2.29.0"}
"""
create_lesson(course, chapter, title, content)
def create_content_lesson_1(course):
title = "Video Content"
chapter = get_chapter(course, "Adding content to your lessons")
content = json.dumps(get_video_content())
create_lesson(course, chapter, title, content)
def create_content_lesson_2(course):
title = "Content from Google Suite"
chapter = get_chapter(course, "Adding content to your lessons")
content = json.dumps(get_google_suite_content())
create_lesson(course, chapter, title, content)
def create_assessment_lesson_1(course):
quiz = create_quiz()
title = "Quiz Time"
chapter = get_chapter(course, "Assessments")
content = f"""{{
"time": 1770118649591,
"blocks": [
{{
"id": "3xqARGZqQa",
"type": "quiz",
"data": {{ "quiz": "{quiz.name}" }}
}}
],
"version": "2.29.0"
}}"""
create_lesson(course, chapter, title, content)
def create_quiz():
title = "Do you know Frappe Learning?"
filters = {"title": title}
if frappe.db.exists("LMS Quiz", filters):
return frappe.get_doc("LMS Quiz", filters)
questions = []
questions.append(
create_quiz_questions(
"What is Frappe Learning primarily used for?",
"Project Management",
False,
"Learning Management",
True,
)
)
questions.append(
create_quiz_questions(
"Which of the following can be added to a course in Frappe Learning?",
"Lessons",
True,
"Issues",
False,
)
)
questions.append(
create_quiz_questions(
"What is the top-level structure in Frappe Learning?", "Chapter", False, "Course", True
)
)
questions.append(
create_quiz_questions("Can you create quizzes in Frappe Learning?", "Yes", True, "No", False)
)
questions.append(
create_quiz_questions(
"Which of the following content can be added to lessons?", "Bugs", False, "Videos", True
)
)
questions.append(
create_quiz_questions("Can you track learner progress in Frappe Learning?", "Yes", True, "No", False)
)
questions.append(
create_quiz_questions(
"What is the purpose of a batch in Frappe Learning?",
"To group learners",
True,
"To store website themes",
False,
)
)
questions.append(
create_quiz_questions(
"How can you create custom certificates in Frappe Learning?",
"Using Server Scripts",
False,
"Using Print Formats",
True,
)
)
quiz = frappe.new_doc("LMS Quiz")
quiz.update(
{
"title": title,
"passing_percentage": 70,
"total_marks": 40,
}
)
for question in questions:
quiz.append(
"questions",
{
"question": question.name,
"marks": 5,
},
)
quiz.save()
return quiz
def create_quiz_questions(question, option_1, is_correct_1, option_2, is_correct_2):
doc = frappe.new_doc("LMS Question")
doc.update(
{
"question": question,
"type": "Choices",
"option_1": option_1,
"is_correct_1": is_correct_1,
"option_2": option_2,
"is_correct_2": is_correct_2,
}
)
doc.save()
return doc
def create_reviews(course, student):
frappe.session.user = student.name
review = frappe.new_doc("LMS Course Review")
review.course = course.name
review.rating = 0.8
review.review = "This is a great course to get started with Frappe Learning. The content is well-structured and easy to follow."
review.save()
frappe.session.user = "Administrator"
update_course_statistics()
def enroll_student_in_course(student, course):
filters = {"member": student.name, "course": course.name}
if not frappe.db.exists("LMS Enrollment", filters):
enrollment = frappe.new_doc("LMS Enrollment")
enrollment.member = student.name
enrollment.course = course.name
enrollment.save()
def create_progress(course, student, limit=None):
lessons = frappe.get_all(
"Course Lesson", {"course": course.name}, pluck="name", limit=limit, order_by="creation asc"
)
for lesson in lessons:
filters = {"member": student.name, "lesson": lesson, "course": course.name}
if not frappe.db.exists("LMS Course Progress", filters):
progress = frappe.new_doc("LMS Course Progress")
progress.member = student.name
progress.lesson = lesson
progress.course = course.name
progress.status = "Complete"
progress.save()
progress = get_course_progress(course.name, student.name)
frappe.db.set_value(
"LMS Enrollment", {"member": student.name, "course": course.name}, "progress", progress
)
def get_video_content():
return {
"time": 1772450228627,
"blocks": [
{
"id": "bj6mK0D36z",
"type": "paragraph",
"data": {
"text": "Frappe Learning allows you to embed videos in lessons using popular video hosting platforms."
},
},
{
"id": "1ooWPn5Zmq",
"type": "paragraph",
"data": {
"text": "You don't need to upload videos directly into Frappe Learning - simply copy the video URL from your preferred provider and paste it into the Lesson Editor."
},
},
{
"id": "tCJD0yMAGd",
"type": "paragraph",
"data": {
"text": "Frappe Learning automatically detects the video source and embeds it for learners."
},
},
{"id": "KpfuszbA09", "type": "markdown", "data": {"text": ""}},
{"id": "PZYmdlzQj2", "type": "header", "data": {"text": "YouTube", "level": 2}},
{
"id": "mJsIbQSHYO",
"type": "paragraph",
"data": {"text": "YouTube videos can be embedded using the standard watch URL."},
},
{"id": "-H8fLBsAMk", "type": "paragraph", "data": {"text": "<b>Supported URL format</b>"}},
{
"id": "Aiq-BfQkwZ",
"type": "paragraph",
"data": {
"text": '<code class="inline-code">https://www.youtube.com/watch?v=&lt;video-id&gt;</code>'
},
},
{"id": "8hMi323AbM", "type": "paragraph", "data": {"text": "<b>Example</b>"}},
{
"id": "3H6BzIshWg",
"type": "paragraph",
"data": {
"text": '<code class="inline-code">https://www.youtube.com/watch?v=SLNSSz41v_o</code>'
},
},
{"id": "yGSuw7Im0i", "type": "markdown", "data": {"text": ""}},
{"id": "WRVOABPAZO", "type": "header", "data": {"text": "Vimeo", "level": 2}},
{
"id": "AabHQjaQvo",
"type": "paragraph",
"data": {"text": "Vimeo videos are supported using the video URL."},
},
{"id": "q_9aNfNHEP", "type": "paragraph", "data": {"text": "<b>Supported URL format</b>"}},
{
"id": "1YYctmoyod",
"type": "paragraph",
"data": {"text": '<code class="inline-code">https://vimeo.com/&lt;video-id&gt;</code>'},
},
{"id": "OX_NGBxJTY", "type": "paragraph", "data": {"text": "<b>Example</b>"}},
{
"id": "KZYnrs_Dnf",
"type": "paragraph",
"data": {"text": '<code class="inline-code">https://vimeo.com/825334862</code>'},
},
{"id": "-mkC711EdF", "type": "markdown", "data": {"text": ""}},
{"id": "nSzyGY6f68", "type": "header", "data": {"text": "Cloudflare Stream", "level": 2}},
{
"id": "-cpNtfvP5T",
"type": "paragraph",
"data": {"text": "Cloudflare Stream provides secure video hosting with adaptive streaming."},
},
{"id": "e2fQ-DG6Nd", "type": "paragraph", "data": {"text": "<b>Supported URL format</b>"}},
{
"id": "av_Q4P66hb",
"type": "paragraph",
"data": {
"text": '<code class="inline-code">https://customer-&lt;account-id&gt;.cloudflarestream.com/&lt;video-id&gt;/watch</code>'
},
},
{"id": "8KCsx40NpJ", "type": "paragraph", "data": {"text": "<b>Example</b>"}},
{
"id": "USi0pW91df",
"type": "paragraph",
"data": {
"text": '<code class="inline-code">https://customer-f33zs165nr7gyfy4.cloudflarestream.com/6b9e68b07dfee8cc2d116e4c51d6a957/watch</code>'
},
},
{"id": "e6I0VuwXx9", "type": "markdown", "data": {"text": ""}},
{"id": "C-u44GnaTz", "type": "header", "data": {"text": "Bunny Stream", "level": 2}},
{
"id": "uR8XZtPVC5",
"type": "paragraph",
"data": {"text": "Bunny Stream allows fast, global video delivery with built-in analytics."},
},
{"id": "BYkm4Hy_v8", "type": "paragraph", "data": {"text": "<b>Supported URL format</b>"}},
{
"id": "TCM9COabp8",
"type": "paragraph",
"data": {
"text": '<code class="inline-code">https://iframe.mediadelivery.net/play/&lt;library-id&gt;/&lt;video-id&gt;</code>'
},
},
{"id": "KCiA6zVRYf", "type": "paragraph", "data": {"text": "<b>Example</b>"}},
{
"id": "kYDFL8Dn1v",
"type": "paragraph",
"data": {
"text": '<code class="inline-code">https://iframe.mediadelivery.net/play/579970/54b3e5a1-cf95-4f88-96d3-8387d93dc2f2</code>'
},
},
{"id": "jfnSgNAv5Q", "type": "markdown", "data": {"text": ""}},
{"id": "NCY3opj8uc", "type": "header", "data": {"text": "Important Notes", "level": 2}},
{
"id": "xHWE56ECqw",
"type": "paragraph",
"data": {"text": "Paste only the video URL, not iframe embed code"},
},
{
"id": "ZzrV99rSxJ",
"type": "paragraph",
"data": {"text": "The URL must match one of the supported formats above"},
},
{
"id": "jjg_inGE2B",
"type": "paragraph",
"data": {
"text": "Video privacy, access control, and streaming limits are managed by the video provider"
},
},
],
"version": "2.29.0",
}
def get_google_suite_content():
return {
"time": 1772450743148,
"blocks": [
{
"id": "73fFo3DS18",
"type": "paragraph",
"data": {
"text": "You can integrate live Google Docs, Sheets, and Slides into your lessons to provide dynamic, up-to-date documentation and presentations."
},
},
{"id": "Z6I1ZV7Fvr", "type": "markdown", "data": {"text": ""}},
{
"id": "hiJVoYEhfN",
"type": "header",
"data": {"text": "How to Embed Google Workspace Files", "level": 3},
},
{
"id": "v9_hXM3d8b",
"type": "list",
"data": {
"style": "ordered",
"items": [
{"content": "Open your Google Doc, Sheet, or Slide.", "items": []},
{"content": "Make sure your permissions are set properly", "items": []},
{"content": "Copy your URL from the top browser address bar", "items": []},
{"content": "Now paste it in your lesson", "items": []},
],
},
},
{"id": "ycS1sd-0us", "type": "markdown", "data": {"text": ""}},
{"id": "NjN6_ixXRW", "type": "header", "data": {"text": "Integration Options", "level": 3}},
{
"id": "MgXDT0xV4X",
"type": "list",
"data": {
"style": "unordered",
"items": [
{
"content": "Google Slides:&nbsp;Perfect for presentations. These render with full navigation controls for the student.",
"items": [],
},
{
"content": "Google Sheets:&nbsp;Useful for sharing live data tables or interactive calculators.",
"items": [],
},
{
"content": "Google Docs:&nbsp;Best for course handouts, reading material, or live-updating documentation.",
"items": [],
},
],
},
},
],
"version": "2.29.0",
}
+8 -5
View File
@@ -8,17 +8,20 @@ app_publisher = "Frappe"
app_description = "Open Source Learning Management System built with Frappe Framework"
app_icon_url = "/assets/lms/images/lms-logo.png"
app_icon_title = "Learning"
app_icon_route = "/lms"
app_color = "grey"
app_email = "jannat@frappe.io"
app_license = "AGPL"
required_apps = ["frappe/payments"]
def get_lms_path():
return (frappe.conf.get("lms_path") or "lms").strip("/")
path = "lms"
if frappe.conf and frappe.conf.get("lms_path"):
path = frappe.conf.get("lms_path")
return path.strip("/")
app_icon_route = f"/{get_lms_path()}"
# Includes in <head>
# ------------------
@@ -71,7 +74,7 @@ web_include_js = []
after_install = "lms.install.after_install"
after_sync = "lms.install.after_sync"
before_uninstall = "lms.install.before_uninstall"
setup_wizard_requires = "assets/lms/js/setup_wizard.js"
setup_wizard_complete = "lms.demo.demo_data.create_demo_data"
after_migrate = [
"lms.sqlite.build_index_in_background",
]
@@ -134,7 +137,7 @@ scheduler_events = {
],
"hourly": [
"lms.lms.doctype.lms_certificate_request.lms_certificate_request.schedule_evals",
"lms.lms.api.update_course_statistics",
"lms.lms.doctype.lms_course.lms_course.update_course_statistics",
"lms.lms.doctype.lms_certificate_request.lms_certificate_request.mark_eval_as_completed",
"lms.lms.doctype.lms_live_class.lms_live_class.update_attendance",
],
-1
View File
@@ -1,5 +1,4 @@
import frappe
from frappe.desk.page.setup_wizard.setup_wizard import add_all_roles_to
from lms.lms.api import give_discussions_permission
+116 -33
View File
@@ -37,6 +37,7 @@ from lms.lms.utils import (
get_average_rating,
get_batch_details,
get_course_details,
get_field_meta,
get_instructors,
get_lesson_count,
get_lms_route,
@@ -55,7 +56,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)
@@ -103,7 +104,54 @@ def validate_billing_access(billing_type: str, name: str):
as_dict=1,
)
return {"access": access, "message": message, "address": address}
payment_fields = get_payment_field_meta()
address_fields = get_field_meta(
"Address",
[
"address_line1",
"address_line2",
"city",
"state",
"country",
"pincode",
"phone",
],
)
billing_field_meta = {**payment_fields, **address_fields}
return {
"access": access,
"message": message,
"address": address,
"billing_field_meta": billing_field_meta,
}
@frappe.whitelist()
def get_payment_field_meta():
return get_field_meta(
"LMS Payment",
[
"member",
"billing_name",
"source",
"payment_for_document_type",
"payment_for_document",
"currency",
"amount",
"amount_with_gst",
"original_amount",
"discount_amount",
"coupon",
"coupon_code",
"address",
"gstin",
"pan",
"payment_id",
"order_id",
"member_consent",
],
)
def verify_billing_access(doctype, name, billing_type):
@@ -702,7 +750,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 +805,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
@@ -775,34 +841,15 @@ def get_new_gateway_fields(doctype: str):
return transformed_fields
def update_course_statistics():
courses = frappe.get_all("LMS Course", fields=["name"])
for course in courses:
lessons = get_lesson_count(course.name)
enrollments = frappe.db.count("LMS Enrollment", {"course": course.name, "member_type": "Student"})
avg_rating = get_average_rating(course.name) or 0
avg_rating = flt(avg_rating, frappe.get_system_settings("float_precision") or 3)
frappe.db.set_value(
"LMS Course",
course.name,
{"lessons": lessons, "enrollments": enrollments, "rating": avg_rating},
)
@frappe.whitelist()
def get_announcements(batch: str):
roles = frappe.get_roles()
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
)
@@ -838,6 +885,7 @@ def delete_course(course: str):
frappe.db.delete("LMS Enrollment", {"course": course})
frappe.db.delete("LMS Course Progress", {"course": course})
frappe.db.delete("LMS Course Review", {"course": course})
frappe.db.set_value("LMS Quiz", {"course": course}, "course", None)
frappe.db.set_value("LMS Quiz Submission", {"course": course}, "course", None)
@@ -1294,6 +1342,7 @@ def get_lms_settings():
"livecode_url",
"disable_pwa",
"allow_job_posting",
"demo_data_present",
]
settings = frappe._dict()
@@ -1309,6 +1358,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",
@@ -1839,23 +1897,27 @@ def get_my_live_classes():
@frappe.whitelist()
def get_created_courses():
created_courses = []
roles = frappe.get_roles()
CourseInstructor = frappe.qb.DocType("Course Instructor")
Course = frappe.qb.DocType("LMS Course")
query = (
base_query = (
frappe.qb.from_(CourseInstructor)
.join(Course)
.on(CourseInstructor.parent == Course.name)
.select(Course.name)
.where(CourseInstructor.instructor == frappe.session.user)
.orderby(Course.published_on, order=frappe.qb.desc)
.limit(3)
)
query = base_query.where(CourseInstructor.instructor == frappe.session.user)
results = query.run(as_dict=True)
courses = [row["name"] for row in results]
if not len(results) and ("Moderator" in roles):
results = base_query.run(as_dict=True)
courses = [row["name"] for row in results]
for course in courses:
course_details = get_course_details(course)
created_courses.append(course_details)
@@ -1928,6 +1990,7 @@ def get_admin_evals():
{
"evaluator": frappe.session.user,
"date": [">=", getdate()],
"status": "Upcoming",
},
[
"name",
@@ -2222,3 +2285,23 @@ def get_badges(member: str):
)
return badges
@frappe.whitelist()
def clear_demo_data():
frappe.only_for("Moderator")
quiz_title = "Do you know Frappe Learning?"
if frappe.db.exists("LMS Quiz", {"title": quiz_title}):
frappe.db.delete("LMS Quiz", {"title": quiz_title})
demo_course = frappe.get_all("LMS Course", {"title": "A guide to Frappe Learning"}, pluck="name")
if len(demo_course):
delete_course(demo_course[0])
users = ["ash@ipp.com", "john.doe@example.com", "jane.smith@example.com", "jannat@example.com"]
for user in users:
if frappe.db.exists("User", user):
frappe.delete_doc("User", user, ignore_permissions=True)
frappe.db.set_single_value("LMS Settings", "demo_data_present", False)
@@ -9,7 +9,7 @@ from frappe.model.document import Document
from frappe.realtime import get_website_room
from frappe.utils.telemetry import capture
from lms.lms.utils import get_course_progress, recalculate_course_progress
from lms.lms.utils import get_course_progress, is_demo_course, recalculate_course_progress
from ...md import find_macros
@@ -127,7 +127,8 @@ def save_progress(lesson: str, course: str, scorm_details: dict = None):
)
progress = get_course_progress(course)
capture_progress_for_analytics()
if not is_demo_course(course):
capture("course_progress", "lms")
# Had to get doc, as on_change doesn't trigger when you use set_value. The trigger is necessary for badge to get assigned.
enrollment = frappe.get_doc("LMS Enrollment", membership)
@@ -145,10 +146,6 @@ def save_progress(lesson: str, course: str, scorm_details: dict = None):
return progress
def capture_progress_for_analytics():
capture("course_progress", "lms")
def get_quiz_progress(lesson):
lesson_details = frappe.db.get_value("Course Lesson", lesson, ["body", "content"], as_dict=1)
quizzes = []
+1
View File
@@ -63,6 +63,7 @@ def eval_condition(doc, condition):
@frappe.whitelist()
def assign_badge(badge_name: str):
frappe.only_for(["Moderator", "Course Creator", "Batch Evaluator"])
assignments = []
badge = frappe.db.get_value(
"LMS Badge",
+16
View File
@@ -36,7 +36,9 @@
"medium",
"confirmation_email_template",
"column_break_flwy",
"conferencing_provider",
"zoom_account",
"google_meet_account",
"notification_sent",
"section_break_jedp",
"video_link",
@@ -361,11 +363,25 @@
"label": "Certification"
},
{
"fieldname": "conferencing_provider",
"fieldtype": "Select",
"label": "Conferencing Provider",
"options": "\nZoom\nGoogle Meet"
},
{
"depends_on": "eval:doc.conferencing_provider=='Zoom'",
"fieldname": "zoom_account",
"fieldtype": "Link",
"label": "Zoom Account",
"options": "LMS Zoom Settings"
},
{
"depends_on": "eval:doc.conferencing_provider=='Google Meet'",
"fieldname": "google_meet_account",
"fieldtype": "Link",
"label": "Google Meet Account",
"options": "LMS Google Meet Settings"
},
{
"fieldname": "video_link",
"fieldtype": "Attach",
+79
View File
@@ -36,6 +36,7 @@ class LMSBatch(Document):
self.validate_duplicate_assessments()
self.validate_timetable()
self.validate_evaluation_end_date()
self.validate_conferencing_provider()
def on_update(self):
if self.has_value_changed("published") and self.published:
@@ -126,6 +127,31 @@ class LMSBatch(Document):
if schedule.date < self.start_date or schedule.date > self.end_date:
frappe.throw(_("Row #{0} Date cannot be outside the batch duration.").format(schedule.idx))
def validate_conferencing_provider(self):
if self.is_new() or not self.conferencing_provider:
return
if self.conferencing_provider == "Google Meet":
if not self.google_meet_account:
frappe.throw(_("Please select a Google Meet account for this batch."))
google_meet_settings = frappe.get_doc("LMS Google Meet Settings", self.google_meet_account)
if not google_meet_settings.enabled:
frappe.throw(
_(
"The selected Google Meet account is disabled. Please enable it or select another account."
)
)
if not google_meet_settings.google_calendar:
frappe.throw(
_("The selected Google Meet account does not have a Google Calendar configured.")
)
elif self.conferencing_provider == "Zoom":
if not self.zoom_account:
frappe.throw(_("Please select a Zoom account for this batch."))
def on_payment_authorized(self, payment_status):
if payment_status in ["Authorized", "Completed"]:
update_payment_record("LMS Batch", self.name)
@@ -262,6 +288,49 @@ def create_live_class(
frappe.throw(_("Error creating live class. Please try again. {0}").format(response.text))
@frappe.whitelist()
def create_google_meet_live_class(
batch_name: str,
google_meet_account: str,
title: str,
duration: int,
date: str,
time: str,
timezone: str,
description: str = None,
):
frappe.only_for(["Moderator", "Batch Evaluator"])
google_meet_settings = frappe.get_doc("LMS Google Meet Settings", google_meet_account)
if not google_meet_settings.enabled:
frappe.throw(_("Please enable the Google Meet account to use this feature."))
if not google_meet_settings.google_calendar:
frappe.throw(
_(
"The Google Meet account does not have a Google Calendar configured. Please set up a Google Calendar first."
)
)
class_details = frappe.get_doc(
{
"doctype": "LMS Live Class",
"title": title,
"host": frappe.session.user,
"date": date,
"time": time,
"duration": duration,
"timezone": timezone,
"description": description,
"batch_name": batch_name,
"conferencing_provider": "Google Meet",
"google_meet_account": google_meet_account,
}
)
class_details.save()
return class_details
def authenticate(zoom_account):
zoom = frappe.get_doc("LMS Zoom Settings", zoom_account)
if not zoom.enabled:
@@ -286,6 +355,16 @@ def authenticate(zoom_account):
@frappe.whitelist()
def get_batch_timetable(batch: str):
roles = frappe.get_roles()
is_batch_student = frappe.db.exists(
"LMS Batch Enrollment", {"batch": batch, "member": frappe.session.user}
)
is_admin = "Moderator" in roles or "Batch Evaluator" in roles
if not (is_batch_student or is_admin):
frappe.throw(
_("You do not have permission to access announcements for this batch."), frappe.PermissionError
)
timetable = frappe.get_all(
"LMS Batch Timetable",
filters={"parent": batch},
@@ -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()
+24 -2
View File
@@ -7,11 +7,13 @@ import frappe
from frappe import _
from frappe.desk.doctype.notification_log.notification_log import make_notification_logs
from frappe.model.document import Document
from frappe.utils import cint, today
from frappe.utils import cint, flt, today
from ...utils import (
generate_slug,
get_average_rating,
get_instructors,
get_lesson_count,
get_lms_route,
update_payment_record,
validate_image,
@@ -47,7 +49,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):
@@ -213,3 +217,21 @@ def send_system_notification_for_published_courses(courses):
)
make_notification_logs(notification, students)
frappe.db.set_value("LMS Course", course.name, "notification_sent", 1)
def update_course_statistics():
courses = frappe.get_all("LMS Course", fields=["name"])
for course in courses:
lessons = get_lesson_count(course.name)
enrollments = frappe.db.count("LMS Enrollment", {"course": course.name, "member_type": "Student"})
avg_rating = get_average_rating(course.name) or 0
avg_rating = flt(avg_rating, frappe.get_system_settings("float_precision") or 3)
frappe.db.set_value(
"LMS Course",
course.name,
{"lessons": lessons, "enrollments": enrollments, "rating": avg_rating},
)
@@ -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()
+4 -3
View File
@@ -154,7 +154,8 @@
"fieldname": "source",
"fieldtype": "Link",
"label": "Source",
"options": "LMS Source"
"options": "LMS Source",
"reqd": 1
},
{
"default": "0",
@@ -202,8 +203,8 @@
"link_fieldname": "payment"
}
],
"modified": "2026-02-03 10:54:12.361407",
"modified_by": "sayali@frappe.io",
"modified": "2026-03-06 17:38:02.235044",
"modified_by": "Administrator",
"module": "LMS",
"name": "LMS Payment",
"owner": "Administrator",
+37 -20
View File
@@ -101,7 +101,7 @@ def set_total_marks(questions: list) -> int:
@frappe.whitelist()
def quiz_summary(quiz: str, results: str):
def submit_quiz(quiz: str, results: str):
results = results and json.loads(results)
percentage = 0
@@ -128,14 +128,13 @@ def quiz_summary(quiz: str, results: str):
score_out_of = quiz_details.total_marks
percentage = (score / score_out_of) * 100 if score_out_of else 0
submission = create_submission(quiz, results, score_out_of, quiz_details.passing_percentage)
save_progress_after_quiz(quiz_details, percentage)
return {
"score": score,
"score_out_of": score_out_of,
"submission": submission.name,
"pass": percentage == quiz_details.passing_percentage,
"pass": percentage >= quiz_details.passing_percentage,
"percentage": percentage,
"is_open_ended": is_open_ended,
}
@@ -158,21 +157,14 @@ def process_results(results: list, quiz_details: dict):
result["marks_out_of"] = question_details.marks
if question_details.type != "Open Ended":
if len(result["is_correct"]) > 0:
correct = result["is_correct"][0]
for point in result["is_correct"]:
correct = correct and point
result["is_correct"] = correct
else:
result["is_correct"] = 0
correct = verify_answer(question_details.question, result["answer"])
result["answer"] = ", ".join(result["answer"])
if correct:
marks = question_details.marks
result["marks"] = question_details.marks
else:
marks = -quiz_details.marks_to_cut if quiz_details.enable_negative_marking else 0
result["marks"] = -quiz_details.marks_to_cut if quiz_details.enable_negative_marking else 0
result["marks"] = marks
score += marks
score += result["marks"]
else:
is_open_ended = True
@@ -188,6 +180,26 @@ def process_results(results: list, quiz_details: dict):
}
def verify_answer(question: str, answer: list):
question_details = get_question_details(question)
correct = False
if question_details.multiple:
for num in range(1, 5):
if question_details[f"option_{num}"] in answer:
correct = question_details[f"is_correct_{num}"]
if not correct:
return False
if question_details[f"is_correct_{num}"] and question_details[f"option_{num}"] not in answer:
return False
return True
for num in range(1, 5):
if question_details[f"option_{num}"] in answer:
correct = question_details[f"is_correct_{num}"]
return correct
def _save_file(match: re.Match) -> str:
data = match.group(1).split("data:")[1]
headers, content = data.split(",")
@@ -258,22 +270,27 @@ def save_progress_after_quiz(quiz_details: dict, percentage: float):
@frappe.whitelist()
def check_answer(question: str, type: str, answers: str):
answers = json.loads(answers)
if type == "Choices":
def check_answer(question: str, question_type: str, answers: str):
answers = answers and json.loads(answers)
if question_type == "Choices":
return check_choice_answers(question, answers)
else:
return check_input_answers(question, answers[0])
def check_choice_answers(question: str, answers: list):
def get_question_details(question: str):
fields = ["multiple"]
is_correct = []
for num in range(1, 5):
fields.append(f"option_{cstr(num)}")
fields.append(f"is_correct_{cstr(num)}")
question_details = frappe.db.get_value("LMS Question", question, fields, as_dict=1)
return question_details
def check_choice_answers(question: str, answers: list):
is_correct = []
question_details = get_question_details(question)
for num in range(1, 5):
if question_details[f"option_{num}"] in answers:
+11 -3
View File
@@ -11,8 +11,9 @@
"send_calendar_invite_for_evaluations",
"column_break_zdel",
"disable_pwa",
"persona_captured",
"default_home",
"persona_captured",
"demo_data_present",
"column_break_bjis",
"unsplash_access_key",
"livecode_url",
@@ -484,14 +485,21 @@
"fieldname": "allow_job_posting",
"fieldtype": "Check",
"label": "Allow Job Posting"
},
{
"default": "0",
"fieldname": "demo_data_present",
"fieldtype": "Check",
"label": "Demo Data Present",
"read_only": 1
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
"modified": "2026-02-19 16:28:15.310145",
"modified_by": "sayali@frappe.io",
"modified": "2026-03-05 13:57:56.303744",
"modified_by": "Administrator",
"module": "LMS",
"name": "LMS Settings",
"owner": "Administrator",
+28 -5
View File
@@ -19,7 +19,6 @@ from frappe.utils import (
get_datetime,
get_frappe_version,
get_fullname,
get_time_str,
getdate,
nowtime,
pretty_date,
@@ -1128,6 +1127,8 @@ def get_batch_details(batch: str):
"timezone",
"category",
"zoom_account",
"conferencing_provider",
"google_meet_account",
],
as_dict=True,
)
@@ -1138,7 +1139,7 @@ def get_batch_details(batch: str):
if (
not batch_details.accept_enrollments
and batch_details.start_date == getdate()
and get_time_str(batch_details.start_time) > nowtime()
and str(batch_details.start_time) > nowtime()
):
batch_details.accept_enrollments = True
@@ -1174,7 +1175,7 @@ def categorize_batches(batches: list) -> dict:
private.append(batch)
elif getdate(batch.start_date) < getdate():
archived.append(batch)
elif getdate(batch.start_date) == getdate() and get_time_str(batch.start_time) < nowtime():
elif getdate(batch.start_date) == getdate() and str(batch.start_time) < nowtime():
archived.append(batch)
else:
upcoming.append(batch)
@@ -2156,14 +2157,14 @@ def filter_batches_based_on_start_time(batches: list, filters: dict) -> list:
batches_to_remove = [
batch
for batch in batches
if getdate(batch.start_date) == getdate() and get_time_str(batch.start_time) < nowtime()
if getdate(batch.start_date) == getdate() and str(batch.start_time) < nowtime()
]
batches = [batch for batch in batches if batch not in batches_to_remove]
elif batchType == "archived":
batches_to_remove = [
batch
for batch in batches
if getdate(batch.start_date) == getdate() and get_time_str(batch.start_time) >= nowtime()
if getdate(batch.start_date) == getdate() and str(batch.start_time) >= nowtime()
]
batches = [batch for batch in batches if batch not in batches_to_remove]
return batches
@@ -2327,3 +2328,25 @@ def recalculate_course_progress(course: str, member: str):
)
frappe.db.set_value("LMS Enrollment", membership, "progress", progress)
update_program_progress(member)
def get_field_meta(doctype, fieldnames):
"""Returns field metadata for 'fieldnames' from 'doctype'"""
meta = frappe.get_meta(doctype)
fieldnames_meta = {}
for fieldname in fieldnames:
field = meta.get_field(fieldname)
if field:
fieldnames_meta[fieldname] = {
"reqd": field.reqd,
"default": field.default,
"description": field.description,
}
return fieldnames_meta
def is_demo_course(course: str) -> bool:
title = frappe.db.get_value("LMS Course", course, "title")
return title == "A guide to Frappe Learning"
+2 -2
View File
@@ -3,7 +3,7 @@ msgstr ""
"Project-Id-Version: frappe\n"
"Report-Msgid-Bugs-To: jannat@frappe.io\n"
"POT-Creation-Date: 2026-01-23 16:05+0000\n"
"PO-Revision-Date: 2026-02-24 17:18\n"
"PO-Revision-Date: 2026-03-03 18:47\n"
"Last-Translator: jannat@frappe.io\n"
"Language-Team: Spanish\n"
"MIME-Version: 1.0\n"
@@ -3307,7 +3307,7 @@ msgstr "Comentarios del instructor"
#. Label of a Link in the LMS Workspace
#: lms/lms/workspace/lms/lms.json
msgid "Interest"
msgstr ""
msgstr "Interés"
#: frontend/src/components/Sidebar/AppSidebar.vue:512
#: frontend/src/components/Sidebar/AppSidebar.vue:515
+1356 -805
View File
File diff suppressed because it is too large Load Diff
+5 -5
View File
@@ -3,7 +3,7 @@ msgstr ""
"Project-Id-Version: frappe\n"
"Report-Msgid-Bugs-To: jannat@frappe.io\n"
"POT-Creation-Date: 2026-01-23 16:05+0000\n"
"PO-Revision-Date: 2026-02-05 06:43\n"
"PO-Revision-Date: 2026-03-07 00: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."
@@ -4850,7 +4850,7 @@ msgstr "Наши предстоящие пакеты"
#. Label of the output (Data) field in DocType 'LMS Test Case Submission'
#: lms/lms/doctype/lms_test_case_submission/lms_test_case_submission.json
msgid "Output"
msgstr ""
msgstr "Выход"
#: frontend/src/components/Settings/BadgeForm.vue:216
#: lms/lms/doctype/lms_badge/lms_badge.js:37
@@ -6337,7 +6337,7 @@ msgstr "Организация запуска"
#: frontend/src/pages/Billing.vue:134
msgid "State/Province"
msgstr ""
msgstr "Штат/провинция"
#. Label of the tab_4_tab (Tab Break) field in DocType 'LMS Course'
#. Label of the statistics (Check) field in DocType 'LMS Settings'
+19 -19
View File
@@ -3,7 +3,7 @@ msgstr ""
"Project-Id-Version: frappe\n"
"Report-Msgid-Bugs-To: jannat@frappe.io\n"
"POT-Creation-Date: 2026-01-23 16:05+0000\n"
"PO-Revision-Date: 2026-02-06 06:38\n"
"PO-Revision-Date: 2026-03-03 18:47\n"
"Last-Translator: jannat@frappe.io\n"
"Language-Team: Swedish\n"
"MIME-Version: 1.0\n"
@@ -41,7 +41,7 @@ msgstr " du är på"
#. Paragraph text in the LMS Workspace
#: lms/lms/workspace/lms/lms.json
msgid "<a href=\"/app/lms-settings/LMS%20Settings\">LMS Settings</a>"
msgstr "<a href=\"/app/lms-settings/LMS%20Settings\">LMS Inställningar</a>"
msgstr "<a href=\"/app/lms-settings/LMS%20Settings\">Inställningar</a>"
#. Paragraph text in the LMS Workspace
#: lms/lms/workspace/lms/lms.json
@@ -70,7 +70,7 @@ msgstr "<p>Kära {{ member_name }},</p>\\n\\n<p>Du har blivit inskriven i vår k
#. Header text in the LMS Workspace
#: lms/lms/workspace/lms/lms.json
msgid "<span class=\"h4\"><b>Get Started</b></span>"
msgstr "<span class=\"h4\"><b>Kom Igång</b></span>"
msgstr "<span class=\"h4\"><b>Genvägar</b></span>"
#. Header text in the LMS Workspace
#: lms/lms/workspace/lms/lms.json
@@ -347,12 +347,12 @@ msgstr "Tillåt Gäst Åtkomst"
#. Label of the allow_posting (Check) field in DocType 'Job Settings'
#: lms/job/doctype/job_settings/job_settings.json
msgid "Allow Job Posting From Website"
msgstr "Tillåt jobbannonsering från webbplats"
msgstr "Tillåt Jobb Annonsering från Webbplats"
#. Label of the allow_self_enrollment (Check) field in DocType 'LMS Batch'
#: lms/lms/doctype/lms_batch/lms_batch.json
msgid "Allow Self Enrollment"
msgstr "Tillåt självregistrering"
msgstr "Tillåt Självregistrering"
#. Label of the allow_future (Check) field in DocType 'LMS Batch'
#: lms/lms/doctype/lms_batch/lms_batch.json
@@ -1624,7 +1624,7 @@ msgstr "Kurs Kapitel"
#. Label of a shortcut in the LMS Workspace
#: lms/lms/workspace/lms/lms.json
msgid "Course Completed"
msgstr "Klara Kurser"
msgstr "Klar Kurs"
#: frontend/src/pages/Statistics.vue:31
msgid "Course Completions"
@@ -1668,7 +1668,7 @@ msgstr "Kursbeskrivning"
#: frontend/src/components/Settings/BadgeForm.vue:203
#: frontend/src/components/Settings/Badges.vue:201
msgid "Course Enrollment"
msgstr "Kurs Inskrivning"
msgstr "Kurs Registrering"
#: frontend/src/pages/Statistics.vue:22
msgid "Course Enrollments"
@@ -2465,7 +2465,7 @@ msgstr "Registrering i denna grupp är begränsad. Vänligen kontakta Administra
#: frontend/src/pages/Programs/ProgramProgressSummary.vue:15
#: lms/lms/doctype/lms_course/lms_course.json lms/lms/workspace/lms/lms.json
msgid "Enrollments"
msgstr "Inskrivningar"
msgstr "Registreringar"
#: lms/lms/doctype/lms_settings/lms_settings.py:27
msgid "Enter Client Id and Client Secret in Google Settings to send calendar invites for evaluations."
@@ -2913,7 +2913,7 @@ msgstr "Portal"
#. Label of the general_tab (Tab Break) field in DocType 'LMS Settings'
#: lms/lms/doctype/lms_settings/lms_settings.json
msgid "General"
msgstr "Allmän"
msgstr "Allmänt"
#: frontend/src/components/Modals/BulkCertificates.vue:5
#: frontend/src/pages/Batch.vue:12
@@ -2937,7 +2937,7 @@ msgstr "Bli Certifierad"
#: lms/templates/onboarding_header.html:8
msgid "Get Started"
msgstr "Kom Igång"
msgstr "Genvägar"
#: frontend/src/components/InstallPrompt.vue:9
msgid "Get the app on your device for easy access & a better experience!"
@@ -3589,7 +3589,7 @@ msgstr "Kurs Intresse"
#. Name of a DocType
#: lms/lms/doctype/lms_course_mentor_mapping/lms_course_mentor_mapping.json
msgid "LMS Course Mentor Mapping"
msgstr "LMS Kurs Mentor Tilldelning"
msgstr "Kurs Mentor Tilldelning"
#. Name of a DocType
#: lms/lms/doctype/lms_course_progress/lms_course_progress.json
@@ -3599,12 +3599,12 @@ msgstr "Kurs Framsteg"
#. Name of a DocType
#: lms/lms/doctype/lms_course_review/lms_course_review.json
msgid "LMS Course Review"
msgstr "LMS Kurs Granskning"
msgstr "Kurs Recension"
#. Name of a DocType
#: lms/lms/doctype/lms_enrollment/lms_enrollment.json
msgid "LMS Enrollment"
msgstr "Inskrivning"
msgstr "Registrering"
#. Name of a DocType
#: lms/job/doctype/lms_job_application/lms_job_application.json
@@ -3614,7 +3614,7 @@ msgstr "Jobb Ansökan"
#. Name of a DocType
#: lms/lms/doctype/lms_lesson_note/lms_lesson_note.json
msgid "LMS Lesson Note"
msgstr "Lektionsanteckning"
msgstr "Lektion Anteckning"
#. Name of a DocType
#: lms/lms/doctype/lms_live_class/lms_live_class.json
@@ -3659,12 +3659,12 @@ msgstr "Program Medlem"
#. Name of a DocType
#: lms/lms/doctype/lms_programming_exercise/lms_programming_exercise.json
msgid "LMS Programming Exercise"
msgstr "Programmeringvning"
msgstr "Programmering Övning"
#. Name of a DocType
#: lms/lms/doctype/lms_programming_exercise_submission/lms_programming_exercise_submission.json
msgid "LMS Programming Exercise Submission"
msgstr "Programmeringvning Inlämning"
msgstr "Programmering Övning Inlämning"
#. Name of a DocType
#: lms/lms/doctype/lms_question/lms_question.json
@@ -7081,7 +7081,7 @@ msgstr "Kommande"
#: frontend/src/pages/Batch.vue:191 frontend/src/pages/Home/AdminHome.vue:34
msgid "Upcoming Batches"
msgstr "Kommande grupper"
msgstr "Kommande Grupper"
#: frontend/src/components/UpcomingEvaluations.vue:5
#: frontend/src/pages/Home/AdminHome.vue:92 lms/templates/upcoming_evals.html:3
@@ -7234,7 +7234,7 @@ msgstr "Violett"
#: frontend/src/components/BatchOverlay.vue:73
msgid "Visit Batch"
msgstr "Besök Omgång"
msgstr "Besök Grupp"
#: frontend/src/pages/JobDetail.vue:52
msgid "Visit Website"
@@ -7610,7 +7610,7 @@ msgstr "Din lektion {0} är idag"
#: frontend/src/components/Modals/EmailTemplateModal.vue:35
msgid "Your enrollment in {{ batch_name }} is confirmed"
msgstr "Din inskrivning till {{ batch_name }} är bekräftad"
msgstr "Din registrering till {{ batch_name }} är bekräftad"
#: lms/lms/notification/certificate_request_reminder/certificate_request_reminder.html:3
#: lms/templates/emails/certificate_request_notification.html:3
+2 -1
View File
@@ -119,4 +119,5 @@ lms.patches.v2_0.open_to_work
lms.patches.v2_0.share_enrollment
lms.patches.v2_0.give_user_list_permission #11-02-2026
lms.patches.v2_0.rename_badge_assignment_event
lms.patches.v2_0.enable_allow_job_posting
lms.patches.v2_0.enable_allow_job_posting
lms.patches.v2_0.set_conferencing_provider_for_zoom
@@ -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",
)
Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB