mirror of
https://github.com/frappe/lms.git
synced 2026-04-19 22:52:29 +03:00
1
.github/helper/install.sh
vendored
1
.github/helper/install.sh
vendored
@@ -11,6 +11,7 @@ cd ./frappe-bench || exit
|
|||||||
bench -v setup requirements
|
bench -v setup requirements
|
||||||
|
|
||||||
echo "Setting Up LMS App..."
|
echo "Setting Up LMS App..."
|
||||||
|
bench get-app "https://github.com/frappe/payments"
|
||||||
bench get-app lms "${GITHUB_WORKSPACE}"
|
bench get-app lms "${GITHUB_WORKSPACE}"
|
||||||
|
|
||||||
echo "Setting Up Sites & Database..."
|
echo "Setting Up Sites & Database..."
|
||||||
|
|||||||
3
.github/workflows/ci.yml
vendored
3
.github/workflows/ci.yml
vendored
@@ -62,6 +62,9 @@ jobs:
|
|||||||
mkdir -p ~/bench-cache
|
mkdir -p ~/bench-cache
|
||||||
(cd && tar czf ~/bench-cache/bench.tgz frappe-bench)
|
(cd && tar czf ~/bench-cache/bench.tgz frappe-bench)
|
||||||
fi
|
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
|
- name: add lms app to bench
|
||||||
working-directory: /home/runner/frappe-bench
|
working-directory: /home/runner/frappe-bench
|
||||||
run: bench get-app lms $GITHUB_WORKSPACE
|
run: bench get-app lms $GITHUB_WORKSPACE
|
||||||
|
|||||||
@@ -104,16 +104,18 @@ describe("Course Creation", () => {
|
|||||||
cy.closeOnboardingModal();
|
cy.closeOnboardingModal();
|
||||||
|
|
||||||
cy.url().should("include", "/lms/courses");
|
cy.url().should("include", "/lms/courses");
|
||||||
cy.get(".grid a:first").within(() => {
|
cy.get("div")
|
||||||
cy.get("div").contains("Test Course");
|
.contains("Test Course")
|
||||||
cy.get("div").contains(
|
.closest("a")
|
||||||
"Test Course Short Introduction to test the UI"
|
.within(() => {
|
||||||
);
|
cy.get("div").contains(
|
||||||
cy.get(".bg-cover")
|
"Test Course Short Introduction to test the UI"
|
||||||
.invoke("css", "background-image")
|
);
|
||||||
.should("include", "/files/profile");
|
cy.get(".bg-cover")
|
||||||
});
|
.invoke("css", "background-image")
|
||||||
cy.get(".grid a:first").click();
|
.should("include", "/files/profile");
|
||||||
|
});
|
||||||
|
cy.get("div").contains("Test Course").closest("a").click();
|
||||||
cy.url().should("include", "/lms/courses/test-course");
|
cy.url().should("include", "/lms/courses/test-course");
|
||||||
cy.get("div").contains("Test Course");
|
cy.get("div").contains("Test Course");
|
||||||
cy.get("div").contains("Test Course Short Introduction to test the UI");
|
cy.get("div").contains("Test Course Short Introduction to test the UI");
|
||||||
@@ -142,7 +144,6 @@ describe("Course Creation", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Add Discussion
|
// Add Discussion
|
||||||
cy.get("span").contains("Community").click();
|
|
||||||
cy.button("New Question").click();
|
cy.button("New Question").click();
|
||||||
cy.wait(500);
|
cy.wait(500);
|
||||||
cy.get("[data-dismissable-layer]").within(() => {
|
cy.get("[data-dismissable-layer]").within(() => {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<FrappeUIProvider>
|
<FrappeUIProvider>
|
||||||
<Layout class="isolate text-base">
|
<Layout class="isolate text-p-base">
|
||||||
<router-view />
|
<router-view />
|
||||||
</Layout>
|
</Layout>
|
||||||
<InstallPrompt v-if="isMobile && !settings.data?.disable_pwa" />
|
<InstallPrompt v-if="isMobile && !settings.data?.disable_pwa" />
|
||||||
|
|||||||
@@ -90,21 +90,26 @@
|
|||||||
{{ __('Get Certificate') }}
|
{{ __('Get Certificate') }}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-4">
|
<div class="space-y-3">
|
||||||
<div class="font-medium text-ink-gray-9">
|
<div class="font-medium text-ink-gray-9">
|
||||||
{{ __('This course has:') }}
|
{{ __('This course has:') }}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center text-ink-gray-9">
|
<div class="flex items-center text-ink-gray-9">
|
||||||
<BookOpen class="h-4 w-4 stroke-1.5" />
|
<BookOpen class="h-4 w-4 stroke-1.5" />
|
||||||
<span class="ml-2">
|
<span class="ml-2">
|
||||||
{{ course.data.lessons }} {{ __('Lessons') }}
|
{{ course.data.lessons }}
|
||||||
|
{{ course.data.lessons > 1 ? __('lessons') : __('lesson') }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center text-ink-gray-9">
|
<div class="flex items-center text-ink-gray-9">
|
||||||
<Users class="h-4 w-4 stroke-1.5" />
|
<Users class="h-4 w-4 stroke-1.5" />
|
||||||
<span class="ml-2">
|
<span class="ml-2">
|
||||||
{{ formatAmount(course.data.enrollments) }}
|
{{ formatAmount(course.data.enrollments) }}
|
||||||
{{ __('Enrolled Students') }}
|
{{
|
||||||
|
course.data.enrollments > 1
|
||||||
|
? __('enrolled students')
|
||||||
|
: __('enrolled student')
|
||||||
|
}}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
@@ -113,7 +118,7 @@
|
|||||||
>
|
>
|
||||||
<Star class="size-4 stroke-1.5 fill-yellow-500 text-transparent" />
|
<Star class="size-4 stroke-1.5 fill-yellow-500 text-transparent" />
|
||||||
<span class="ml-2">
|
<span class="ml-2">
|
||||||
{{ course.data.rating }} {{ __('Rating') }}
|
{{ course.data.rating }} {{ __('average rating') }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -4,9 +4,7 @@
|
|||||||
<div class="text-lg font-semibold text-ink-gray-7 mb-2.5">
|
<div class="text-lg font-semibold text-ink-gray-7 mb-2.5">
|
||||||
{{ __('No {0}').format(type?.toLowerCase()) }}
|
{{ __('No {0}').format(type?.toLowerCase()) }}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div class="text-p-base w-full md:w-2/5 text-center text-ink-gray-7">
|
||||||
class="leading-5 text-base w-full md:w-2/5 text-base text-center text-ink-gray-7"
|
|
||||||
>
|
|
||||||
{{
|
{{
|
||||||
__(
|
__(
|
||||||
'There are no {0} currently. Keep an eye out, fresh learning experiences are on the way!'
|
'There are no {0} currently. Keep an eye out, fresh learning experiences are on the way!'
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
: __('Edit Assignment')
|
: __('Edit Assignment')
|
||||||
}}
|
}}
|
||||||
</div>
|
</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
|
<FormControl
|
||||||
v-model="assignment.title"
|
v-model="assignment.title"
|
||||||
:label="__('Title')"
|
:label="__('Title')"
|
||||||
@@ -43,7 +43,7 @@
|
|||||||
@change="(val) => (assignment.question = val)"
|
@change="(val) => (assignment.question = val)"
|
||||||
:editable="true"
|
:editable="true"
|
||||||
:fixedMenu="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>
|
||||||
</div>
|
</div>
|
||||||
@@ -73,7 +73,7 @@
|
|||||||
import { Button, Dialog, FormControl, TextEditor, toast } from 'frappe-ui'
|
import { Button, Dialog, FormControl, TextEditor, toast } from 'frappe-ui'
|
||||||
import { computed, reactive, watch } from 'vue'
|
import { computed, reactive, watch } from 'vue'
|
||||||
import { escapeHTML, sanitizeHTML } from '@/utils'
|
import { escapeHTML, sanitizeHTML } from '@/utils'
|
||||||
import { Link } from 'frappe-ui/frappe'
|
import Link from '@/components/Controls/Link.vue'
|
||||||
|
|
||||||
const show = defineModel()
|
const show = defineModel()
|
||||||
const assignments = defineModel<Assignments>('assignments')
|
const assignments = defineModel<Assignments>('assignments')
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<Dialog
|
<Dialog
|
||||||
v-model="show"
|
v-model="show"
|
||||||
:options="{
|
:options="{
|
||||||
size: '5xl',
|
size: '3xl',
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<template #body>
|
<template #body>
|
||||||
@@ -10,17 +10,14 @@
|
|||||||
<div class="text-lg font-semibold text-ink-gray-9 mb-5">
|
<div class="text-lg font-semibold text-ink-gray-9 mb-5">
|
||||||
{{ __(props.title) }}
|
{{ __(props.title) }}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<Switch
|
||||||
v-if="!editMode"
|
v-if="!editMode"
|
||||||
class="flex items-center text-xs text-ink-gray-7 space-x-5"
|
size="sm"
|
||||||
>
|
:label="__('Choose an existing question')"
|
||||||
<Switch
|
:description="__('Select from questions you have already created')"
|
||||||
size="sm"
|
v-model="chooseFromExisting"
|
||||||
:label="__('Choose an existing question')"
|
class="!p-0"
|
||||||
v-model="chooseFromExisting"
|
/>
|
||||||
class="!p-0"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div v-if="!chooseFromExisting || editMode">
|
<div v-if="!chooseFromExisting || editMode">
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs text-ink-gray-5 mb-1">
|
<label class="block text-xs text-ink-gray-5 mb-1">
|
||||||
@@ -164,7 +161,7 @@ populateFields()
|
|||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
title: {
|
title: {
|
||||||
type: String,
|
type: String,
|
||||||
default: __('Add a new question'),
|
default: __('Add new question'),
|
||||||
},
|
},
|
||||||
questionDetail: {
|
questionDetail: {
|
||||||
type: [Object, null],
|
type: [Object, null],
|
||||||
|
|||||||
@@ -31,7 +31,7 @@
|
|||||||
<Search class="size-4 stroke-1.5 text-ink-gray-5" />
|
<Search class="size-4 stroke-1.5 text-ink-gray-5" />
|
||||||
</template>
|
</template>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<div class="overflow-auto h-[60vh]">
|
<div class="overflow-auto max-h-[60vh]">
|
||||||
<div class="divide-y divide-outline-gray-modals">
|
<div class="divide-y divide-outline-gray-modals">
|
||||||
<div
|
<div
|
||||||
v-for="evaluator in evaluators.data"
|
v-for="evaluator in evaluators.data"
|
||||||
|
|||||||
@@ -31,7 +31,7 @@
|
|||||||
<Search class="size-4 stroke-1.5 text-ink-gray-5" />
|
<Search class="size-4 stroke-1.5 text-ink-gray-5" />
|
||||||
</template>
|
</template>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<div class="overflow-y-scroll h-[60vh]">
|
<div class="overflow-y-scroll max-h-[60vh]">
|
||||||
<ul class="divide-y divide-outline-gray-modals">
|
<ul class="divide-y divide-outline-gray-modals">
|
||||||
<li
|
<li
|
||||||
v-for="member in memberList"
|
v-for="member in memberList"
|
||||||
|
|||||||
@@ -65,7 +65,7 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { sessionStore } from '@/stores/session'
|
import { sessionStore } from '@/stores/session'
|
||||||
import { Dropdown } from 'frappe-ui'
|
import { call, Dropdown, toast } from 'frappe-ui'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { convertToTitleCase } from '@/utils'
|
import { convertToTitleCase } from '@/utils'
|
||||||
import { usersStore } from '@/stores/user'
|
import { usersStore } from '@/stores/user'
|
||||||
@@ -85,7 +85,7 @@ import {
|
|||||||
User,
|
User,
|
||||||
Settings,
|
Settings,
|
||||||
Sun,
|
Sun,
|
||||||
Zap,
|
Trash2,
|
||||||
} from 'lucide-vue-next'
|
} from 'lucide-vue-next'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -175,6 +175,19 @@ const userDropdownOptions = computed(() => {
|
|||||||
return userResource.data?.is_moderator
|
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,
|
icon: FrappeCloudIcon,
|
||||||
label: 'Login to Frappe Cloud',
|
label: 'Login to Frappe Cloud',
|
||||||
@@ -234,4 +247,36 @@ const loginToFrappeCloud = () => {
|
|||||||
let redirect_to = '/dashboard/sites/' + userResource.data.sitename
|
let redirect_to = '/dashboard/sites/' + userResource.data.sitename
|
||||||
window.open(`${frappeCloudBaseEndpoint}${redirect_to}`, '_blank')
|
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>
|
</script>
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<Dropdown
|
<Dropdown
|
||||||
v-else-if="isAdmin"
|
v-else-if="isAdmin && batchMenu.length"
|
||||||
:options="batchMenu"
|
:options="batchMenu"
|
||||||
placement="left"
|
placement="left"
|
||||||
side="left"
|
side="left"
|
||||||
@@ -209,6 +209,9 @@ const canMakeAnnouncement = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const batchMenu = computed(() => {
|
const batchMenu = computed(() => {
|
||||||
|
if (!batch.data?.certification && !canMakeAnnouncement()) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
let options = [
|
let options = [
|
||||||
{
|
{
|
||||||
label: __('Generate Certificates'),
|
label: __('Generate Certificates'),
|
||||||
|
|||||||
@@ -56,7 +56,7 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="!readOnlyMode">
|
<div v-if="!readOnlyMode && !canAccessBatch">
|
||||||
<router-link
|
<router-link
|
||||||
:to="{
|
:to="{
|
||||||
name: 'Billing',
|
name: 'Billing',
|
||||||
@@ -71,7 +71,7 @@
|
|||||||
batch.data.accept_enrollments
|
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>
|
<template #prefix>
|
||||||
<CreditCard class="size-4 stroke-1.5" />
|
<CreditCard class="size-4 stroke-1.5" />
|
||||||
</template>
|
</template>
|
||||||
@@ -173,14 +173,6 @@ const isEvaluator = computed(() => {
|
|||||||
return user.data?.is_evaluator
|
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(() => {
|
const canAccessBatch = computed(() => {
|
||||||
if (!user.data) {
|
if (!user.data) {
|
||||||
return false
|
return false
|
||||||
@@ -188,7 +180,7 @@ const canAccessBatch = computed(() => {
|
|||||||
return isModerator.value || isStudent.value || isEvaluator.value
|
return isModerator.value || isStudent.value || isEvaluator.value
|
||||||
})
|
})
|
||||||
|
|
||||||
const canEditBatch = computed(() => {
|
const isAdmin = computed(() => {
|
||||||
return isModerator.value || isInstructor.value
|
return isModerator.value || isEvaluator.value
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
v-model="batch.title"
|
v-model="batch.title"
|
||||||
:label="__('Title')"
|
:label="__('Title')"
|
||||||
:required="true"
|
:required="true"
|
||||||
|
autocomplete="off"
|
||||||
/>
|
/>
|
||||||
<FormControl
|
<FormControl
|
||||||
v-model="batch.start_date"
|
v-model="batch.start_date"
|
||||||
@@ -42,6 +43,7 @@
|
|||||||
v-model="batch.timezone"
|
v-model="batch.timezone"
|
||||||
:label="__('Timezone')"
|
:label="__('Timezone')"
|
||||||
:required="true"
|
:required="true"
|
||||||
|
autocomplete="off"
|
||||||
/>
|
/>
|
||||||
<Link
|
<Link
|
||||||
doctype="LMS Category"
|
doctype="LMS Category"
|
||||||
@@ -72,6 +74,13 @@
|
|||||||
|
|
||||||
<div class="space-y-5 border-t mt-5 pt-5">
|
<div class="space-y-5 border-t mt-5 pt-5">
|
||||||
<div class="grid grid-cols-2 gap-5">
|
<div class="grid grid-cols-2 gap-5">
|
||||||
|
<FormControl
|
||||||
|
v-model="batch.description"
|
||||||
|
:label="__('Description')"
|
||||||
|
type="textarea"
|
||||||
|
:required="true"
|
||||||
|
:rows="4"
|
||||||
|
/>
|
||||||
<MultiSelect
|
<MultiSelect
|
||||||
v-model="batch.instructors"
|
v-model="batch.instructors"
|
||||||
doctype="Course Evaluator"
|
doctype="Course Evaluator"
|
||||||
@@ -80,13 +89,6 @@
|
|||||||
:onCreate="(close: () => void) => openSettings('Evaluators', close)"
|
:onCreate="(close: () => void) => openSettings('Evaluators', close)"
|
||||||
:filters="{ ignore_user_type: 1 }"
|
:filters="{ ignore_user_type: 1 }"
|
||||||
/>
|
/>
|
||||||
<FormControl
|
|
||||||
v-model="batch.description"
|
|
||||||
:label="__('Description')"
|
|
||||||
type="textarea"
|
|
||||||
:required="true"
|
|
||||||
:rows="4"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="">
|
<div class="">
|
||||||
<div class="mb-1.5 text-sm text-ink-gray-5">
|
<div class="mb-1.5 text-sm text-ink-gray-5">
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="pl-5">
|
<div class="pl-5">
|
||||||
<div class="grid grid-cols-1 md:grid-cols-[70%,30%]">
|
<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="my-5">
|
||||||
<div class="pr-5 md:pr-10 pb-5 mb-5 space-y-5 border-b">
|
<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">
|
<div class="text-lg font-semibold mb-4 text-ink-gray-9">
|
||||||
@@ -71,7 +74,11 @@
|
|||||||
<ColorSwatches
|
<ColorSwatches
|
||||||
v-model="courseResource.doc.card_gradient"
|
v-model="courseResource.doc.card_gradient"
|
||||||
:label="__('Color')"
|
: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"
|
class="w-full"
|
||||||
@update:modelValue="makeFormDirty()"
|
@update:modelValue="makeFormDirty()"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -87,6 +87,9 @@ const identifyUserPersona = async () => {
|
|||||||
if (personaCaptured) return
|
if (personaCaptured) return
|
||||||
let courseCount = await call('frappe.client.get_count', {
|
let courseCount = await call('frappe.client.get_count', {
|
||||||
doctype: 'LMS Course',
|
doctype: 'LMS Course',
|
||||||
|
filters: {
|
||||||
|
title: ['not like', '%A guide to Frappe Learning%'],
|
||||||
|
},
|
||||||
})
|
})
|
||||||
if (!courseCount) {
|
if (!courseCount) {
|
||||||
router.push({ name: 'PersonaForm' })
|
router.push({ name: 'PersonaForm' })
|
||||||
|
|||||||
@@ -65,7 +65,7 @@
|
|||||||
</router-link>
|
</router-link>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</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 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="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">
|
<div class="flex items-center justify-center mt-4 space-x-2">
|
||||||
@@ -263,7 +263,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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"
|
class="mt-10 pb-20 pt-5 border-t px-5"
|
||||||
ref="discussionsContainer"
|
ref="discussionsContainer"
|
||||||
>
|
>
|
||||||
@@ -399,15 +399,10 @@ const { brand } = sessionStore()
|
|||||||
const sidebarStore = useSidebar()
|
const sidebarStore = useSidebar()
|
||||||
const plyrSources = ref([])
|
const plyrSources = ref([])
|
||||||
const showInlineMenu = ref(false)
|
const showInlineMenu = ref(false)
|
||||||
const currentTab = ref('Notes')
|
const currentTab = ref(null)
|
||||||
let timerInterval = null
|
let timerInterval = null
|
||||||
|
|
||||||
const tabs = ref([
|
const tabs = ref([])
|
||||||
{
|
|
||||||
label: __('Notes'),
|
|
||||||
value: 'Notes',
|
|
||||||
},
|
|
||||||
])
|
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
courseName: {
|
courseName: {
|
||||||
@@ -887,24 +882,24 @@ const updateNotes = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
watch(allowDiscussions, () => {
|
watch(allowDiscussions, () => {
|
||||||
if (allowDiscussions.value) {
|
if (!isAdmin.value) {
|
||||||
tabs.value = [
|
if (!tabs.value.find((tab) => tab.value === 'Notes')) {
|
||||||
{
|
tabs.value.push({
|
||||||
label: __('Notes'),
|
label: __('Notes'),
|
||||||
value: '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'),
|
label: __('Community'),
|
||||||
value: 'Community',
|
value: 'Community',
|
||||||
},
|
})
|
||||||
]
|
}
|
||||||
} else {
|
|
||||||
tabs.value = [
|
|
||||||
{
|
|
||||||
label: __('Notes'),
|
|
||||||
value: 'Notes',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -199,11 +199,16 @@ const evaluator = createResource({
|
|||||||
if (data.slots.unavailable_from) from.value = data.slots.unavailable_from
|
if (data.slots.unavailable_from) from.value = data.slots.unavailable_from
|
||||||
if (data.slots.unavailable_to) to.value = data.slots.unavailable_to
|
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({
|
const createSlot = createResource({
|
||||||
url: 'frappe.client.insert',
|
url: 'frappe.client.insert',
|
||||||
makeParams(values) {
|
makeParams(values) {
|
||||||
|
console.log(evaluator.data)
|
||||||
return {
|
return {
|
||||||
doc: {
|
doc: {
|
||||||
doctype: 'Evaluator Schedule',
|
doctype: 'Evaluator Schedule',
|
||||||
|
|||||||
@@ -51,7 +51,7 @@
|
|||||||
@change="(val: string) => (exercise.problem_statement = val)"
|
@change="(val: string) => (exercise.problem_statement = val)"
|
||||||
:editable="true"
|
:editable="true"
|
||||||
:fixedMenu="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>
|
||||||
</div>
|
</div>
|
||||||
@@ -71,6 +71,7 @@
|
|||||||
{{ __('Delete') }}
|
{{ __('Delete') }}
|
||||||
</Button>
|
</Button>
|
||||||
<router-link
|
<router-link
|
||||||
|
v-if="exerciseID != 'new'"
|
||||||
:to="{
|
:to="{
|
||||||
name: 'ProgrammingExerciseSubmission',
|
name: 'ProgrammingExerciseSubmission',
|
||||||
params: {
|
params: {
|
||||||
@@ -87,6 +88,7 @@
|
|||||||
</Button>
|
</Button>
|
||||||
</router-link>
|
</router-link>
|
||||||
<router-link
|
<router-link
|
||||||
|
v-if="exerciseID != 'new'"
|
||||||
:to="{
|
:to="{
|
||||||
name: 'ProgrammingExerciseSubmissions',
|
name: 'ProgrammingExerciseSubmissions',
|
||||||
query: {
|
query: {
|
||||||
@@ -148,6 +150,7 @@ const languageOptions = [
|
|||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
exerciseID: string
|
exerciseID: string
|
||||||
|
getExerciseCount: () => Promise<number>
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
exerciseID: 'new',
|
exerciseID: 'new',
|
||||||
@@ -185,7 +188,6 @@ const setExerciseData = () => {
|
|||||||
const testCases = createListResource({
|
const testCases = createListResource({
|
||||||
doctype: 'LMS Test Case',
|
doctype: 'LMS Test Case',
|
||||||
fields: ['input', 'expected_output', 'name'],
|
fields: ['input', 'expected_output', 'name'],
|
||||||
cache: ['testCases', props.exerciseID],
|
|
||||||
parent: 'LMS Programming Exercise',
|
parent: 'LMS Programming Exercise',
|
||||||
orderBy: 'idx',
|
orderBy: 'idx',
|
||||||
onSuccess(data: TestCase[]) {
|
onSuccess(data: TestCase[]) {
|
||||||
@@ -207,7 +209,7 @@ const fetchTestCases = () => {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
testCases.reload()
|
testCases.reload()
|
||||||
originalTestCaseCount.value = testCases.data.length
|
originalTestCaseCount.value = testCases.data?.length
|
||||||
}
|
}
|
||||||
|
|
||||||
const validateTitle = () => {
|
const validateTitle = () => {
|
||||||
@@ -223,7 +225,7 @@ watch(
|
|||||||
)
|
)
|
||||||
|
|
||||||
watch(testCases, () => {
|
watch(testCases, () => {
|
||||||
if (testCases.data.length !== originalTestCaseCount.value) {
|
if (testCases.data?.length !== originalTestCaseCount.value) {
|
||||||
isDirty.value = true
|
isDirty.value = true
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -255,6 +257,7 @@ const createNewExercise = (close: () => void) => {
|
|||||||
close()
|
close()
|
||||||
isDirty.value = false
|
isDirty.value = false
|
||||||
exercises.value?.reload()
|
exercises.value?.reload()
|
||||||
|
props.getExerciseCount()
|
||||||
toast.success(__('Programming Exercise created successfully'))
|
toast.success(__('Programming Exercise created successfully'))
|
||||||
},
|
},
|
||||||
onError(err: any) {
|
onError(err: any) {
|
||||||
|
|||||||
@@ -300,7 +300,7 @@ const loadFalcon = () => {
|
|||||||
}
|
}
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const script = document.createElement('script')
|
const script = document.createElement('script')
|
||||||
script.src = `${falconURL.value}static/livecode.js`
|
script.src = `${falconURL.value}/static/livecode.js`
|
||||||
script.onload = resolve
|
script.onload = resolve
|
||||||
script.onerror = reject
|
script.onerror = reject
|
||||||
document.head.appendChild(script)
|
document.head.appendChild(script)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
<Breadcrumbs :items="breadcrumbs" />
|
<Breadcrumbs :items="breadcrumbs" />
|
||||||
<div class="space-x-2">
|
<div class="space-x-2">
|
||||||
<router-link
|
<router-link
|
||||||
|
v-if="exercises.data?.length"
|
||||||
:to="{
|
:to="{
|
||||||
name: 'ProgrammingExerciseSubmissions',
|
name: 'ProgrammingExerciseSubmissions',
|
||||||
}"
|
}"
|
||||||
@@ -120,8 +121,9 @@
|
|||||||
</div>
|
</div>
|
||||||
<ProgrammingExerciseForm
|
<ProgrammingExerciseForm
|
||||||
v-model="showForm"
|
v-model="showForm"
|
||||||
:exerciseID="exerciseID"
|
|
||||||
v-model:exercises="exercises"
|
v-model:exercises="exercises"
|
||||||
|
:exerciseID="exerciseID"
|
||||||
|
:getExerciseCount="getExerciseCount"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
@@ -152,7 +154,7 @@ const exerciseCount = ref<number>(0)
|
|||||||
const readOnlyMode = window.read_only_mode
|
const readOnlyMode = window.read_only_mode
|
||||||
const { brand } = sessionStore()
|
const { brand } = sessionStore()
|
||||||
const showForm = ref<boolean>(false)
|
const showForm = ref<boolean>(false)
|
||||||
const exerciseID = ref<string | null>('new')
|
const exerciseID = ref<string>('new')
|
||||||
const user = inject<any>('$user')
|
const user = inject<any>('$user')
|
||||||
const titleFilter = ref<string>('')
|
const titleFilter = ref<string>('')
|
||||||
const languageFilter = ref<string>('')
|
const languageFilter = ref<string>('')
|
||||||
|
|||||||
@@ -194,11 +194,7 @@
|
|||||||
v-model="showQuestionModal"
|
v-model="showQuestionModal"
|
||||||
:questionDetail="currentQuestion"
|
:questionDetail="currentQuestion"
|
||||||
v-model:quiz="quizDetails"
|
v-model:quiz="quizDetails"
|
||||||
:title="
|
:title="currentQuestion.question ? __('Edit Question') : __('Add Question')"
|
||||||
currentQuestion.question
|
|
||||||
? __('Edit the question')
|
|
||||||
: __('Add a new question')
|
|
||||||
"
|
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
|
|||||||
@@ -12,12 +12,8 @@
|
|||||||
</header>
|
</header>
|
||||||
<div class="py-5 mx-5">
|
<div class="py-5 mx-5">
|
||||||
<div class="flex items-center justify-between mb-4">
|
<div class="flex items-center justify-between mb-4">
|
||||||
<div class="text-lg font-semibold text-ink-gray-7">
|
<div class="text-lg font-semibold">
|
||||||
{{
|
{{ __('{0} Quizzes').format(quizzes.data.length) }}
|
||||||
quizzes.data?.length
|
|
||||||
? __('{0} Quizzes').format(quizzes.data.length)
|
|
||||||
: __('No Quizzes')
|
|
||||||
}}
|
|
||||||
</div>
|
</div>
|
||||||
<FormControl v-model="search" type="text" placeholder="Search">
|
<FormControl v-model="search" type="text" placeholder="Search">
|
||||||
<template #prefix>
|
<template #prefix>
|
||||||
@@ -116,6 +112,7 @@
|
|||||||
v-model="title"
|
v-model="title"
|
||||||
:label="__('Title')"
|
:label="__('Title')"
|
||||||
type="text"
|
type="text"
|
||||||
|
autocomplete="off"
|
||||||
@keydown.enter="insertQuiz(() => (showForm = false))"
|
@keydown.enter="insertQuiz(() => (showForm = false))"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
548
lms/demo/demo_data.py
Normal file
548
lms/demo/demo_data.py
Normal 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=<video-id></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/<video-id></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-<account-id>.cloudflarestream.com/<video-id>/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/<library-id>/<video-id></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: Perfect for presentations. These render with full navigation controls for the student.",
|
||||||
|
"items": [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"content": "Google Sheets: Useful for sharing live data tables or interactive calculators.",
|
||||||
|
"items": [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"content": "Google Docs: Best for course handouts, reading material, or live-updating documentation.",
|
||||||
|
"items": [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"version": "2.29.0",
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ app_icon_route = "/lms"
|
|||||||
app_color = "grey"
|
app_color = "grey"
|
||||||
app_email = "jannat@frappe.io"
|
app_email = "jannat@frappe.io"
|
||||||
app_license = "AGPL"
|
app_license = "AGPL"
|
||||||
|
required_apps = ["frappe/payments"]
|
||||||
|
|
||||||
|
|
||||||
def get_lms_path():
|
def get_lms_path():
|
||||||
@@ -73,7 +74,7 @@ web_include_js = []
|
|||||||
after_install = "lms.install.after_install"
|
after_install = "lms.install.after_install"
|
||||||
after_sync = "lms.install.after_sync"
|
after_sync = "lms.install.after_sync"
|
||||||
before_uninstall = "lms.install.before_uninstall"
|
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 = [
|
after_migrate = [
|
||||||
"lms.sqlite.build_index_in_background",
|
"lms.sqlite.build_index_in_background",
|
||||||
]
|
]
|
||||||
@@ -136,7 +137,7 @@ scheduler_events = {
|
|||||||
],
|
],
|
||||||
"hourly": [
|
"hourly": [
|
||||||
"lms.lms.doctype.lms_certificate_request.lms_certificate_request.schedule_evals",
|
"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_certificate_request.lms_certificate_request.mark_eval_as_completed",
|
||||||
"lms.lms.doctype.lms_live_class.lms_live_class.update_attendance",
|
"lms.lms.doctype.lms_live_class.lms_live_class.update_attendance",
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import frappe
|
import frappe
|
||||||
from frappe.desk.page.setup_wizard.setup_wizard import add_all_roles_to
|
|
||||||
|
|
||||||
from lms.lms.api import give_discussions_permission
|
from lms.lms.api import give_discussions_permission
|
||||||
|
|
||||||
|
|||||||
@@ -841,24 +841,6 @@ def get_new_gateway_fields(doctype: str):
|
|||||||
return transformed_fields
|
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()
|
@frappe.whitelist()
|
||||||
def get_announcements(batch: str):
|
def get_announcements(batch: str):
|
||||||
roles = frappe.get_roles()
|
roles = frappe.get_roles()
|
||||||
@@ -903,6 +885,7 @@ def delete_course(course: str):
|
|||||||
|
|
||||||
frappe.db.delete("LMS Enrollment", {"course": course})
|
frappe.db.delete("LMS Enrollment", {"course": course})
|
||||||
frappe.db.delete("LMS Course Progress", {"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", {"course": course}, "course", None)
|
||||||
frappe.db.set_value("LMS Quiz Submission", {"course": course}, "course", None)
|
frappe.db.set_value("LMS Quiz Submission", {"course": course}, "course", None)
|
||||||
|
|
||||||
@@ -1359,6 +1342,7 @@ def get_lms_settings():
|
|||||||
"livecode_url",
|
"livecode_url",
|
||||||
"disable_pwa",
|
"disable_pwa",
|
||||||
"allow_job_posting",
|
"allow_job_posting",
|
||||||
|
"demo_data_present",
|
||||||
]
|
]
|
||||||
|
|
||||||
settings = frappe._dict()
|
settings = frappe._dict()
|
||||||
@@ -1913,23 +1897,27 @@ def get_my_live_classes():
|
|||||||
@frappe.whitelist()
|
@frappe.whitelist()
|
||||||
def get_created_courses():
|
def get_created_courses():
|
||||||
created_courses = []
|
created_courses = []
|
||||||
|
roles = frappe.get_roles()
|
||||||
|
|
||||||
CourseInstructor = frappe.qb.DocType("Course Instructor")
|
CourseInstructor = frappe.qb.DocType("Course Instructor")
|
||||||
Course = frappe.qb.DocType("LMS Course")
|
Course = frappe.qb.DocType("LMS Course")
|
||||||
|
|
||||||
query = (
|
base_query = (
|
||||||
frappe.qb.from_(CourseInstructor)
|
frappe.qb.from_(CourseInstructor)
|
||||||
.join(Course)
|
.join(Course)
|
||||||
.on(CourseInstructor.parent == Course.name)
|
.on(CourseInstructor.parent == Course.name)
|
||||||
.select(Course.name)
|
.select(Course.name)
|
||||||
.where(CourseInstructor.instructor == frappe.session.user)
|
|
||||||
.orderby(Course.published_on, order=frappe.qb.desc)
|
.orderby(Course.published_on, order=frappe.qb.desc)
|
||||||
.limit(3)
|
.limit(3)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
query = base_query.where(CourseInstructor.instructor == frappe.session.user)
|
||||||
results = query.run(as_dict=True)
|
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:
|
for course in courses:
|
||||||
course_details = get_course_details(course)
|
course_details = get_course_details(course)
|
||||||
created_courses.append(course_details)
|
created_courses.append(course_details)
|
||||||
@@ -2002,6 +1990,7 @@ def get_admin_evals():
|
|||||||
{
|
{
|
||||||
"evaluator": frappe.session.user,
|
"evaluator": frappe.session.user,
|
||||||
"date": [">=", getdate()],
|
"date": [">=", getdate()],
|
||||||
|
"status": "Upcoming",
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
"name",
|
"name",
|
||||||
@@ -2296,3 +2285,23 @@ def get_badges(member: str):
|
|||||||
)
|
)
|
||||||
|
|
||||||
return badges
|
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.realtime import get_website_room
|
||||||
from frappe.utils.telemetry import capture
|
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
|
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)
|
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.
|
# 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)
|
enrollment = frappe.get_doc("LMS Enrollment", membership)
|
||||||
@@ -145,10 +146,6 @@ def save_progress(lesson: str, course: str, scorm_details: dict = None):
|
|||||||
return progress
|
return progress
|
||||||
|
|
||||||
|
|
||||||
def capture_progress_for_analytics():
|
|
||||||
capture("course_progress", "lms")
|
|
||||||
|
|
||||||
|
|
||||||
def get_quiz_progress(lesson):
|
def get_quiz_progress(lesson):
|
||||||
lesson_details = frappe.db.get_value("Course Lesson", lesson, ["body", "content"], as_dict=1)
|
lesson_details = frappe.db.get_value("Course Lesson", lesson, ["body", "content"], as_dict=1)
|
||||||
quizzes = []
|
quizzes = []
|
||||||
|
|||||||
@@ -7,11 +7,13 @@ import frappe
|
|||||||
from frappe import _
|
from frappe import _
|
||||||
from frappe.desk.doctype.notification_log.notification_log import make_notification_logs
|
from frappe.desk.doctype.notification_log.notification_log import make_notification_logs
|
||||||
from frappe.model.document import Document
|
from frappe.model.document import Document
|
||||||
from frappe.utils import cint, today
|
from frappe.utils import cint, flt, today
|
||||||
|
|
||||||
from ...utils import (
|
from ...utils import (
|
||||||
generate_slug,
|
generate_slug,
|
||||||
|
get_average_rating,
|
||||||
get_instructors,
|
get_instructors,
|
||||||
|
get_lesson_count,
|
||||||
get_lms_route,
|
get_lms_route,
|
||||||
update_payment_record,
|
update_payment_record,
|
||||||
validate_image,
|
validate_image,
|
||||||
@@ -215,3 +217,21 @@ def send_system_notification_for_published_courses(courses):
|
|||||||
)
|
)
|
||||||
make_notification_logs(notification, students)
|
make_notification_logs(notification, students)
|
||||||
frappe.db.set_value("LMS Course", course.name, "notification_sent", 1)
|
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},
|
||||||
|
)
|
||||||
|
|||||||
@@ -154,7 +154,8 @@
|
|||||||
"fieldname": "source",
|
"fieldname": "source",
|
||||||
"fieldtype": "Link",
|
"fieldtype": "Link",
|
||||||
"label": "Source",
|
"label": "Source",
|
||||||
"options": "LMS Source"
|
"options": "LMS Source",
|
||||||
|
"reqd": 1
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"default": "0",
|
"default": "0",
|
||||||
@@ -202,8 +203,8 @@
|
|||||||
"link_fieldname": "payment"
|
"link_fieldname": "payment"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"modified": "2026-02-03 10:54:12.361407",
|
"modified": "2026-03-06 17:38:02.235044",
|
||||||
"modified_by": "sayali@frappe.io",
|
"modified_by": "Administrator",
|
||||||
"module": "LMS",
|
"module": "LMS",
|
||||||
"name": "LMS Payment",
|
"name": "LMS Payment",
|
||||||
"owner": "Administrator",
|
"owner": "Administrator",
|
||||||
|
|||||||
@@ -11,8 +11,9 @@
|
|||||||
"send_calendar_invite_for_evaluations",
|
"send_calendar_invite_for_evaluations",
|
||||||
"column_break_zdel",
|
"column_break_zdel",
|
||||||
"disable_pwa",
|
"disable_pwa",
|
||||||
"persona_captured",
|
|
||||||
"default_home",
|
"default_home",
|
||||||
|
"persona_captured",
|
||||||
|
"demo_data_present",
|
||||||
"column_break_bjis",
|
"column_break_bjis",
|
||||||
"unsplash_access_key",
|
"unsplash_access_key",
|
||||||
"livecode_url",
|
"livecode_url",
|
||||||
@@ -484,14 +485,21 @@
|
|||||||
"fieldname": "allow_job_posting",
|
"fieldname": "allow_job_posting",
|
||||||
"fieldtype": "Check",
|
"fieldtype": "Check",
|
||||||
"label": "Allow Job Posting"
|
"label": "Allow Job Posting"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"default": "0",
|
||||||
|
"fieldname": "demo_data_present",
|
||||||
|
"fieldtype": "Check",
|
||||||
|
"label": "Demo Data Present",
|
||||||
|
"read_only": 1
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"grid_page_length": 50,
|
"grid_page_length": 50,
|
||||||
"index_web_pages_for_search": 1,
|
"index_web_pages_for_search": 1,
|
||||||
"issingle": 1,
|
"issingle": 1,
|
||||||
"links": [],
|
"links": [],
|
||||||
"modified": "2026-02-19 16:28:15.310145",
|
"modified": "2026-03-05 13:57:56.303744",
|
||||||
"modified_by": "sayali@frappe.io",
|
"modified_by": "Administrator",
|
||||||
"module": "LMS",
|
"module": "LMS",
|
||||||
"name": "LMS Settings",
|
"name": "LMS Settings",
|
||||||
"owner": "Administrator",
|
"owner": "Administrator",
|
||||||
|
|||||||
@@ -2345,3 +2345,8 @@ def get_field_meta(doctype, fieldnames):
|
|||||||
}
|
}
|
||||||
|
|
||||||
return fieldnames_meta
|
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"
|
||||||
|
|||||||
BIN
lms/public/images/course_card.jpeg
Normal file
BIN
lms/public/images/course_card.jpeg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 120 KiB |
BIN
lms/public/images/instructor.png
Normal file
BIN
lms/public/images/instructor.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.8 KiB |
BIN
lms/public/images/student.jpg
Normal file
BIN
lms/public/images/student.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
BIN
lms/public/images/student1.jpeg
Normal file
BIN
lms/public/images/student1.jpeg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.3 KiB |
BIN
lms/public/images/student2.jpeg
Normal file
BIN
lms/public/images/student2.jpeg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.5 KiB |
@@ -1,116 +0,0 @@
|
|||||||
{
|
|
||||||
"app": "lms",
|
|
||||||
"creation": "2025-11-24 14:35:18.461657",
|
|
||||||
"docstatus": 0,
|
|
||||||
"doctype": "Workspace Sidebar",
|
|
||||||
"header_icon": "education",
|
|
||||||
"idx": 0,
|
|
||||||
"items": [
|
|
||||||
{
|
|
||||||
"child": 0,
|
|
||||||
"collapsible": 1,
|
|
||||||
"indent": 0,
|
|
||||||
"keep_closed": 0,
|
|
||||||
"label": "Home",
|
|
||||||
"link_to": "Learning",
|
|
||||||
"link_type": "Workspace",
|
|
||||||
"show_arrow": 0,
|
|
||||||
"type": "Link"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"child": 0,
|
|
||||||
"collapsible": 1,
|
|
||||||
"indent": 0,
|
|
||||||
"keep_closed": 0,
|
|
||||||
"label": "Users",
|
|
||||||
"link_to": "User",
|
|
||||||
"link_type": "DocType",
|
|
||||||
"show_arrow": 0,
|
|
||||||
"type": "Link"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"child": 0,
|
|
||||||
"collapsible": 1,
|
|
||||||
"indent": 0,
|
|
||||||
"keep_closed": 0,
|
|
||||||
"label": "Course",
|
|
||||||
"link_to": "LMS Course",
|
|
||||||
"link_type": "DocType",
|
|
||||||
"show_arrow": 0,
|
|
||||||
"type": "Link"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"child": 0,
|
|
||||||
"collapsible": 1,
|
|
||||||
"indent": 0,
|
|
||||||
"keep_closed": 0,
|
|
||||||
"label": "Enrollments",
|
|
||||||
"link_to": "LMS Enrollment",
|
|
||||||
"link_type": "DocType",
|
|
||||||
"show_arrow": 0,
|
|
||||||
"type": "Link"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"child": 0,
|
|
||||||
"collapsible": 1,
|
|
||||||
"indent": 0,
|
|
||||||
"keep_closed": 0,
|
|
||||||
"label": "Batch",
|
|
||||||
"link_to": "LMS Batch",
|
|
||||||
"link_type": "DocType",
|
|
||||||
"show_arrow": 0,
|
|
||||||
"type": "Link"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"child": 0,
|
|
||||||
"collapsible": 1,
|
|
||||||
"indent": 0,
|
|
||||||
"keep_closed": 0,
|
|
||||||
"label": "Batch Enrollment",
|
|
||||||
"link_to": "LMS Batch Enrollment",
|
|
||||||
"link_type": "DocType",
|
|
||||||
"show_arrow": 0,
|
|
||||||
"type": "Link"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"child": 0,
|
|
||||||
"collapsible": 1,
|
|
||||||
"indent": 0,
|
|
||||||
"keep_closed": 0,
|
|
||||||
"label": "Evaluation Request",
|
|
||||||
"link_to": "LMS Certificate Request",
|
|
||||||
"link_type": "DocType",
|
|
||||||
"show_arrow": 0,
|
|
||||||
"type": "Link"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"child": 0,
|
|
||||||
"collapsible": 1,
|
|
||||||
"indent": 0,
|
|
||||||
"keep_closed": 0,
|
|
||||||
"label": "Evaluation",
|
|
||||||
"link_to": "LMS Certificate Evaluation",
|
|
||||||
"link_type": "DocType",
|
|
||||||
"show_arrow": 0,
|
|
||||||
"type": "Link"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"child": 0,
|
|
||||||
"collapsible": 1,
|
|
||||||
"indent": 0,
|
|
||||||
"keep_closed": 0,
|
|
||||||
"label": "Certificate",
|
|
||||||
"link_to": "LMS Certificate",
|
|
||||||
"link_type": "DocType",
|
|
||||||
"show_arrow": 0,
|
|
||||||
"type": "Link"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"modified": "2026-03-02 13:07:37.040316",
|
|
||||||
"modified_by": "sayali@frappe.io",
|
|
||||||
"module": "LMS",
|
|
||||||
"name": "LMS",
|
|
||||||
"owner": "Administrator",
|
|
||||||
"standard": 1,
|
|
||||||
"title": "LMS"
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user