Merge pull request #1087 from frappe/develop

chore: merge 'develop' into 'main'
This commit is contained in:
Jannat Patel
2024-10-31 12:15:12 +05:30
committed by GitHub
43 changed files with 68139 additions and 4707 deletions

View File

@@ -13,6 +13,6 @@ module.exports = defineConfig({
openMode: 0, openMode: 0,
}, },
e2e: { e2e: {
baseUrl: "http://test_site_ui:8000", baseUrl: "http://test:8000",
}, },
}); });

View File

@@ -5,7 +5,7 @@ describe("Course Creation", () => {
cy.visit("/lms/courses"); cy.visit("/lms/courses");
// Create a course // Create a course
cy.get("a").contains("New").click(); cy.get("header").children().last().children().last().click();
cy.wait(1000); cy.wait(1000);
cy.url().should("include", "/courses/new/edit"); cy.url().should("include", "/courses/new/edit");
@@ -73,7 +73,7 @@ describe("Course Creation", () => {
.should("be.visible") .should("be.visible")
.within(() => { .within(() => {
cy.get("label").contains("Title").type("Test Chapter"); cy.get("label").contains("Title").type("Test Chapter");
cy.button("Add Chapter").click(); cy.button("Create").click();
}); });
// Add Lesson // Add Lesson

View File

@@ -23,7 +23,7 @@
"codemirror-editor-vue3": "^2.8.0", "codemirror-editor-vue3": "^2.8.0",
"dayjs": "^1.11.6", "dayjs": "^1.11.6",
"feather-icons": "^4.28.0", "feather-icons": "^4.28.0",
"frappe-ui": "^0.1.69", "frappe-ui": "^0.1.72",
"lucide-vue-next": "^0.383.0", "lucide-vue-next": "^0.383.0",
"markdown-it": "^14.0.0", "markdown-it": "^14.0.0",
"pinia": "^2.0.33", "pinia": "^2.0.33",

View File

@@ -2,6 +2,7 @@
<div> <div>
<label class="block mb-1" :class="labelClasses" v-if="label"> <label class="block mb-1" :class="labelClasses" v-if="label">
{{ label }} {{ label }}
<span class="text-red-500" v-if="required">*</span>
</label> </label>
<div class="grid grid-cols-3 gap-1"> <div class="grid grid-cols-3 gap-1">
<Button <Button
@@ -115,6 +116,9 @@ const props = defineProps({
type: Function, type: Function,
default: (value) => `${value} is an Invalid value`, default: (value) => `${value} is an Invalid value`,
}, },
required: {
type: Boolean,
},
}) })
const values = defineModel() const values = defineModel()

View File

@@ -30,29 +30,29 @@
</div> </div>
<div class="flex flex-col flex-auto p-4"> <div class="flex flex-col flex-auto p-4">
<div class="flex items-center justify-between mb-2"> <div class="flex items-center justify-between mb-2">
<div v-if="course.lesson_count"> <div v-if="course.lessons">
<Tooltip :text="__('Lessons')"> <Tooltip :text="__('Lessons')">
<span class="flex items-center"> <span class="flex items-center">
<BookOpen class="h-4 w-4 stroke-1.5 text-gray-700 mr-1" /> <BookOpen class="h-4 w-4 stroke-1.5 text-gray-700 mr-1" />
{{ course.lesson_count }} {{ course.lessons }}
</span> </span>
</Tooltip> </Tooltip>
</div> </div>
<div v-if="course.enrollment_count"> <div v-if="course.enrollments">
<Tooltip :text="__('Enrolled Students')"> <Tooltip :text="__('Enrolled Students')">
<span class="flex items-center"> <span class="flex items-center">
<Users class="h-4 w-4 stroke-1.5 text-gray-700 mr-1" /> <Users class="h-4 w-4 stroke-1.5 text-gray-700 mr-1" />
{{ course.enrollment_count }} {{ course.enrollments }}
</span> </span>
</Tooltip> </Tooltip>
</div> </div>
<div v-if="course.avg_rating"> <div v-if="course.rating">
<Tooltip :text="__('Average Rating')"> <Tooltip :text="__('Average Rating')">
<span class="flex items-center"> <span class="flex items-center">
<Star class="h-4 w-4 stroke-1.5 text-gray-700 mr-1" /> <Star class="h-4 w-4 stroke-1.5 text-gray-700 mr-1" />
{{ course.avg_rating }} {{ course.rating }}
</span> </span>
</Tooltip> </Tooltip>
</div> </div>

View File

@@ -93,21 +93,19 @@
<div class="flex items-center mb-3"> <div class="flex items-center mb-3">
<BookOpen class="h-5 w-5 stroke-1.5 text-gray-600" /> <BookOpen class="h-5 w-5 stroke-1.5 text-gray-600" />
<span class="ml-2"> <span class="ml-2">
{{ course.data.lesson_count }} {{ __('Lessons') }} {{ course.data.lessons }} {{ __('Lessons') }}
</span> </span>
</div> </div>
<div class="flex items-center mb-3"> <div class="flex items-center mb-3">
<Users class="h-5 w-5 stroke-1.5 text-gray-600" /> <Users class="h-5 w-5 stroke-1.5 text-gray-600" />
<span class="ml-2"> <span class="ml-2">
{{ course.data.enrollment_count_formatted }} {{ formatAmount(course.data.enrollments) }}
{{ __('Enrolled Students') }} {{ __('Enrolled Students') }}
</span> </span>
</div> </div>
<div class="flex items-center"> <div class="flex items-center">
<Star class="h-5 w-5 stroke-1.5 fill-orange-500 text-gray-50" /> <Star class="h-5 w-5 stroke-1.5 fill-orange-500 text-gray-50" />
<span class="ml-2"> <span class="ml-2"> {{ course.data.rating }} {{ __('Rating') }} </span>
{{ course.data.avg_rating }} {{ __('Rating') }}
</span>
</div> </div>
</div> </div>
</div> </div>
@@ -116,7 +114,7 @@
import { BookOpen, Users, Star } from 'lucide-vue-next' import { BookOpen, Users, Star } from 'lucide-vue-next'
import { computed, inject } from 'vue' import { computed, inject } from 'vue'
import { Button, createResource } from 'frappe-ui' import { Button, createResource } from 'frappe-ui'
import { showToast } from '@/utils/' import { showToast, formatAmount } from '@/utils/'
import { capture } from '@/telemetry' import { capture } from '@/telemetry'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'

View File

@@ -76,7 +76,7 @@ const props = defineProps({
required: true, required: true,
}, },
avg_rating: { avg_rating: {
type: Number, type: String,
required: true, required: true,
}, },
membership: { membership: {

View File

@@ -21,7 +21,7 @@
<div class="space-y-2"> <div class="space-y-2">
<div <div
class="flex items-center text-sm font-medium space-x-2 cursor-pointer" class="flex text-sm font-medium space-x-2 cursor-pointer"
@click="openHelpDialog('upload')" @click="openHelpDialog('upload')"
> >
<span class="leading-5"> <span class="leading-5">
@@ -56,6 +56,21 @@
}} }}
</div> </div>
</div> </div>
<div class="space-y-2">
<div class="flex items-center text-sm font-medium space-x-2">
<span>
{{ __('What does include in preview mean?') }}
</span>
</div>
<div class="text-xs text-gray-600 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.'
)
}}
</div>
</div>
</div> </div>
<ExplanationVideos v-model="showExplanation" :type="type" /> <ExplanationVideos v-model="showExplanation" :type="type" />
</template> </template>

View File

@@ -2,11 +2,11 @@
<Dialog <Dialog
v-model="show" v-model="show"
:options="{ :options="{
title: __('Add Chapter'), title: chapterDetail ? __('Edit Chapter') : __('Add Chapter'),
size: 'lg', size: 'lg',
actions: [ actions: [
{ {
label: chapterDetail ? __('Edit Chapter') : __('Add Chapter'), label: chapterDetail ? __('Edit') : __('Create'),
variant: 'solid', variant: 'solid',
onClick: (close) => onClick: (close) =>
chapterDetail ? editChapter(close) : addChapter(close), chapterDetail ? editChapter(close) : addChapter(close),

View File

@@ -1,7 +1,7 @@
<template> <template>
<div v-if="quiz.data"> <div v-if="quiz.data">
<div <div
class="bg-blue-100 space-y-1 py-2 px-2 rounded-md text-sm text-blue-800" class="bg-blue-100 space-y-1 py-2 px-2 mb-4 rounded-md text-sm text-blue-800"
> >
<div class="leading-5"> <div class="leading-5">
{{ {{

View File

@@ -32,57 +32,65 @@
</div> </div>
</div> </div>
<div class="mb-4"> <div class="mb-4">
<div> <div class="text-xs text-gray-600 mb-2">
<FileUploader {{ __('Meta Image') }}
v-if="!batch.image" </div>
class="mt-4" <FileUploader
:fileTypes="['image/*']" v-if="!batch.image"
:validateFile="validateFile" :fileTypes="['image/*']"
@success="(file) => saveImage(file)" :validateFile="validateFile"
> @success="(file) => saveImage(file)"
<template v-slot="{ file, progress, uploading, openFileSelector }"> >
<div class="mb-4"> <template v-slot="{ file, progress, uploading, openFileSelector }">
<Button @click="openFileSelector" :loading="uploading">
{{ uploading ? `Uploading ${progress}%` : 'Upload an image' }}
</Button>
</div>
</template>
</FileUploader>
<div v-else class="mb-4">
<div class="text-xs text-gray-600 mb-1">
{{ __('Meta Image') }}
</div>
<div class="flex items-center"> <div class="flex items-center">
<div class="border rounded-md p-2 mr-2"> <div class="border rounded-md w-fit py-5 px-20">
<FileText class="h-5 w-5 stroke-1.5 text-gray-700" /> <Image class="size-5 stroke-1 text-gray-700" />
</div> </div>
<div class="flex flex-col"> <div class="ml-4">
<span> <Button @click="openFileSelector">
{{ batch.image.file_name }} {{ __('Upload') }}
</span> </Button>
<span class="text-sm text-gray-500 mt-1"> <div class="mt-2 text-gray-600 text-sm">
{{ getFileSize(batch.image.file_size) }} {{
</span> __(
'Appears when the batch URL is shared on any online platform'
)
}}
</div>
</div>
</div>
</template>
</FileUploader>
<div v-else class="mb-4">
<div class="flex items-center">
<img :src="batch.image.file_url" class="border rounded-md w-40" />
<div class="ml-4">
<Button @click="removeImage()">
{{ __('Remove') }}
</Button>
<div class="mt-2 text-gray-600 text-sm">
{{
__(
'Appears when the batch URL is shared on any online platform'
)
}}
</div> </div>
<X
@click="removeImage()"
class="bg-gray-200 rounded-md cursor-pointer stroke-1.5 w-5 h-5 p-1 ml-4"
/>
</div> </div>
</div> </div>
</div> </div>
<MultiSelect
v-model="instructors"
doctype="User"
:label="__('Instructors')"
/>
</div> </div>
<MultiSelect
v-model="instructors"
doctype="User"
:label="__('Instructors')"
/>
<div class="mb-4"> <div class="mb-4">
<FormControl <FormControl
v-model="batch.description" v-model="batch.description"
:label="__('Description')" :label="__('Description')"
type="textarea" type="textarea"
class="my-4" class="my-4"
:placeholder="__('Short description of the batch')"
/> />
<div> <div>
<label class="block text-sm text-gray-600 mb-1"> <label class="block text-sm text-gray-600 mb-1">
@@ -133,6 +141,7 @@
v-model="batch.timezone" v-model="batch.timezone"
:label="__('Timezone')" :label="__('Timezone')"
type="text" type="text"
:placeholder="__('Example: IST (+5:30)')"
class="mb-4" class="mb-4"
/> />
</div> </div>
@@ -149,6 +158,7 @@
:label="__('Seat Count')" :label="__('Seat Count')"
type="number" type="number"
class="mb-4" class="mb-4"
:placeholder="__('Number of seats available')"
/> />
<FormControl <FormControl
v-model="batch.evaluation_end_date" v-model="batch.evaluation_end_date"
@@ -228,11 +238,11 @@ import {
createResource, createResource,
} from 'frappe-ui' } from 'frappe-ui'
import Link from '@/components/Controls/Link.vue' import Link from '@/components/Controls/Link.vue'
import MultiSelect from '@/components/Controls/MultiSelect.vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { getFileSize, showToast } from '../utils' import { showToast } from '../utils'
import { X, FileText } from 'lucide-vue-next' import { Image } from 'lucide-vue-next'
import { capture } from '@/telemetry' import { capture } from '@/telemetry'
import MultiSelect from '@/components/Controls/MultiSelect.vue'
const router = useRouter() const router = useRouter()
const user = inject('$user') const user = inject('$user')

View File

@@ -40,6 +40,7 @@
{{ __('Loading Batches...') }} {{ __('Loading Batches...') }}
</div> </div>
<Tabs <Tabs
v-if="hasBatches"
v-model="tabIndex" v-model="tabIndex"
:tabs="makeTabs" :tabs="makeTabs"
tablistClass="overflow-x-visible flex-wrap !gap-3 md:flex-nowrap" tablistClass="overflow-x-visible flex-wrap !gap-3 md:flex-nowrap"
@@ -79,24 +80,63 @@
<BatchCard :batch="batch" /> <BatchCard :batch="batch" />
</router-link> </router-link>
</div> </div>
<div <div v-else class="p-5 italic text-gray-500">
v-else {{ __('No {0} batches').format(tab.label.toLowerCase()) }}
class="grid flex-1 place-items-center text-xl font-medium text-gray-500"
>
<div class="flex flex-col items-center justify-center mt-4">
<div>
{{ __('No {0} batches found').format(tab.label.toLowerCase()) }}
</div>
</div>
</div> </div>
</template> </template>
</Tabs> </Tabs>
<div
v-else-if="
!batches.loading &&
!hasBatches &&
(user.data?.is_instructor || user.data?.is_moderator)
"
class="grid grid-cols-3 p-5"
>
<router-link
:to="{
name: 'BatchForm',
params: {
batchName: 'new',
},
}"
>
<div class="bg-gray-50 py-32 px-5 rounded-md">
<div class="flex flex-col items-center text-center space-y-2">
<Plus
class="size-10 stroke-1 text-gray-800 p-1 rounded-full border bg-white"
/>
<div class="font-medium">
{{ __('Create a Batch') }}
</div>
<span class="text-gray-700 text-sm leading-4">
{{ __('You can link courses and assessments to it.') }}
</span>
</div>
</div>
</router-link>
</div>
<div
v-else-if="!batches.loading && !hasBatches"
class="text-center p-5 text-gray-600 mt-52 w-3/4 md:w-1/2 mx-auto space-y-2"
>
<BookOpen class="size-10 mx-auto stroke-1 text-gray-500" />
<div class="text-xl font-medium">
{{ __('No batches found') }}
</div>
<div>
{{
__(
'There are no batches available at the moment. Keep an eye out, fresh learning experiences are on the way soon!'
)
}}
</div>
</div>
</div> </div>
</div> </div>
</template> </template>
<script setup> <script setup>
import { import {
createListResource,
createResource, createResource,
Breadcrumbs, Breadcrumbs,
Button, Button,
@@ -104,13 +144,14 @@ import {
Badge, Badge,
Select, Select,
} from 'frappe-ui' } from 'frappe-ui'
import { Plus } from 'lucide-vue-next' import { BookOpen, Plus } from 'lucide-vue-next'
import BatchCard from '@/components/BatchCard.vue' import BatchCard from '@/components/BatchCard.vue'
import { inject, ref, computed, onMounted, watch } from 'vue' import { inject, ref, computed, onMounted, watch } from 'vue'
import { updateDocumentTitle } from '@/utils' import { updateDocumentTitle } from '@/utils'
const user = inject('$user') const user = inject('$user')
const currentCategory = ref(null) const currentCategory = ref(null)
const hasBatches = ref(false)
onMounted(() => { onMounted(() => {
let queries = new URLSearchParams(location.search) let queries = new URLSearchParams(location.search)
@@ -119,10 +160,10 @@ onMounted(() => {
} }
}) })
const batches = createListResource({ const batches = createResource({
doctype: 'LMS Batch', doctype: 'LMS Batch',
url: 'lms.lms.utils.get_batches', url: 'lms.lms.utils.get_batches',
cache: ['batches', user?.data?.email], cache: ['batches', user.data?.email],
auto: true, auto: true,
}) })
@@ -183,6 +224,14 @@ const addToTabs = (label) => {
}) })
} }
watch(batches, () => {
Object.keys(batches.data).forEach((key) => {
if (batches.data[key].length) {
hasBatches.value = true
}
})
})
watch( watch(
() => currentCategory.value, () => currentCategory.value,
() => { () => {

View File

@@ -16,16 +16,16 @@
</div> </div>
<div class="flex items-center"> <div class="flex items-center">
<Tooltip <Tooltip
v-if="course.data.avg_rating" v-if="course.data.rating"
:text="__('Average Rating')" :text="__('Average Rating')"
class="flex items-center" class="flex items-center"
> >
<Star class="h-5 w-5 text-gray-100 fill-orange-500" /> <Star class="h-5 w-5 text-gray-100 fill-orange-500" />
<span class="ml-1"> <span class="ml-1">
{{ course.data.avg_rating }} {{ course.data.rating }}
</span> </span>
</Tooltip> </Tooltip>
<span v-if="course.data.avg_rating" class="mx-3">&middot;</span> <span v-if="course.data.rating" class="mx-3">&middot;</span>
<Tooltip <Tooltip
v-if="course.data.enrollment_count" v-if="course.data.enrollment_count"
:text="__('Enrolled Students')" :text="__('Enrolled Students')"
@@ -74,7 +74,7 @@
</div> </div>
<CourseReviews <CourseReviews
:courseName="course.data.name" :courseName="course.data.name"
:avg_rating="course.data.avg_rating" :avg_rating="course.data.rating"
:membership="course.data.membership" :membership="course.data.membership"
/> />
</div> </div>
@@ -116,7 +116,7 @@ const breadcrumbs = computed(() => {
let items = [{ label: 'All Courses', route: { name: 'Courses' } }] let items = [{ label: 'All Courses', route: { name: 'Courses' } }]
items.push({ items.push({
label: course?.data?.title, label: course?.data?.title,
route: { name: 'CourseDetail', params: { course: course?.data?.name } }, route: { name: 'CourseDetail', params: { courseName: course?.data?.name } },
}) })
return items return items
}) })

View File

@@ -23,15 +23,23 @@
v-model="course.title" v-model="course.title"
:label="__('Title')" :label="__('Title')"
class="mb-4" class="mb-4"
:required="true"
/> />
<FormControl <FormControl
v-model="course.short_introduction" v-model="course.short_introduction"
:label="__('Short Introduction')" :label="__('Short Introduction')"
:placeholder="
__(
'A one line introduction to the course that appears on the course card'
)
"
class="mb-4" class="mb-4"
:required="true"
/> />
<div class="mb-4"> <div class="mb-4">
<div class="mb-1.5 text-sm text-gray-700"> <div class="mb-1.5 text-sm text-gray-600">
{{ __('Course Description') }} {{ __('Course Description') }}
<span class="text-red-500">*</span>
</div> </div>
<TextEditor <TextEditor
:content="course.description" :content="course.description"
@@ -41,49 +49,62 @@
editorClass="prose-sm max-w-none border-b border-x bg-gray-100 rounded-b-md py-1 px-2 min-h-[7rem]" editorClass="prose-sm max-w-none border-b border-x bg-gray-100 rounded-b-md py-1 px-2 min-h-[7rem]"
/> />
</div> </div>
<FileUploader <div class="mb-4">
v-if="!course.course_image" <div class="text-xs text-gray-600 mb-2">
:fileTypes="['image/*']"
:validateFile="validateFile"
@success="(file) => saveImage(file)"
>
<template
v-slot="{ file, progress, uploading, openFileSelector }"
>
<div class="mb-4">
<Button @click="openFileSelector" :loading="uploading">
{{
uploading ? `Uploading ${progress}%` : 'Upload an image'
}}
</Button>
</div>
</template>
</FileUploader>
<div v-else class="mb-4">
<div class="text-xs text-gray-600 mb-1">
{{ __('Course Image') }} {{ __('Course Image') }}
<span class="text-red-500">*</span>
</div> </div>
<div class="flex items-center"> <FileUploader
<div class="border rounded-md p-2 mr-2"> v-if="!course.course_image"
<FileText class="h-5 w-5 stroke-1.5 text-gray-700" /> :fileTypes="['image/*']"
:validateFile="validateFile"
@success="(file) => saveImage(file)"
>
<template
v-slot="{ file, progress, uploading, openFileSelector }"
>
<div class="flex items-center">
<div class="border rounded-md w-fit py-5 px-20">
<Image class="size-5 stroke-1 text-gray-700" />
</div>
<div class="ml-4">
<Button @click="openFileSelector">
{{ __('Upload') }}
</Button>
<div class="mt-2 text-gray-600 text-sm">
{{
__('Appears on the course card in the course list')
}}
</div>
</div>
</div>
</template>
</FileUploader>
<div v-else class="mb-4">
<div class="flex items-center">
<img
:src="course.course_image.file_url"
class="border rounded-md w-40"
/>
<div class="ml-4">
<Button @click="removeImage()">
{{ __('Remove') }}
</Button>
<div class="mt-2 text-gray-600 text-sm">
{{ __('Appears on the course card in the course list') }}
</div>
</div>
</div> </div>
<div class="flex flex-col">
<span>
{{ course.course_image.file_name }}
</span>
<span class="text-sm text-gray-500 mt-1">
{{ getFileSize(course.course_image.file_size) }}
</span>
</div>
<X
@click="removeImage()"
class="bg-gray-200 rounded-md cursor-pointer stroke-1.5 w-5 h-5 p-1 ml-4"
/>
</div> </div>
</div> </div>
<FormControl <FormControl
v-model="course.video_link" v-model="course.video_link"
:label="__('Preview Video')" :label="__('Preview Video')"
:placeholder="
__(
'Paste the youtube link of a short video introducing the course'
)
"
class="mb-4" class="mb-4"
/> />
<div class="mb-4"> <div class="mb-4">
@@ -104,6 +125,8 @@
</div> </div>
<FormControl <FormControl
v-model="newTag" v-model="newTag"
:placeholder="__('Keywords for the course')"
class="w-52"
@keyup.enter="updateTags()" @keyup.enter="updateTags()"
id="tags" id="tags"
/> />
@@ -121,6 +144,7 @@
v-model="instructors" v-model="instructors"
doctype="User" doctype="User"
:label="__('Instructors')" :label="__('Instructors')"
:required="true"
/> />
</div> </div>
<div class="container border-t"> <div class="container border-t">
@@ -130,7 +154,7 @@
<div class="grid grid-cols-3 gap-10 mb-4"> <div class="grid grid-cols-3 gap-10 mb-4">
<div <div
v-if="user.data?.is_moderator" v-if="user.data?.is_moderator"
class="flex flex-col space-y-3" class="flex flex-col space-y-4"
> >
<FormControl <FormControl
type="checkbox" type="checkbox"
@@ -231,7 +255,7 @@ import {
updateDocumentTitle, updateDocumentTitle,
} from '@/utils' } from '@/utils'
import Link from '@/components/Controls/Link.vue' import Link from '@/components/Controls/Link.vue'
import { FileText, X } from 'lucide-vue-next' import { FileText, Image, X } from 'lucide-vue-next'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import CourseOutline from '@/components/CourseOutline.vue' import CourseOutline from '@/components/CourseOutline.vue'
import MultiSelect from '@/components/Controls/MultiSelect.vue' import MultiSelect from '@/components/Controls/MultiSelect.vue'

View File

@@ -8,7 +8,7 @@
:items="[{ label: __('Courses'), route: { name: 'Courses' } }]" :items="[{ label: __('Courses'), route: { name: 'Courses' } }]"
/> />
<div class="flex space-x-2 justify-end"> <div class="flex space-x-2 justify-end">
<div class="w-46 md:w-44"> <div class="w-40 md:w-44">
<FormControl <FormControl
v-if="categories.data?.length" v-if="categories.data?.length"
type="select" type="select"
@@ -48,6 +48,7 @@
</header> </header>
<div class=""> <div class="">
<Tabs <Tabs
v-if="hasCourses"
v-model="tabIndex" v-model="tabIndex"
tablistClass="overflow-x-visible flex-wrap !gap-3 md:flex-nowrap" tablistClass="overflow-x-visible flex-wrap !gap-3 md:flex-nowrap"
:tabs="makeTabs" :tabs="makeTabs"
@@ -101,18 +102,57 @@
<CourseCard :course="course" /> <CourseCard :course="course" />
</router-link> </router-link>
</div> </div>
<div <div v-else class="p-5 italic text-gray-500">
v-else {{ __('No {0} courses').format(tab.label.toLowerCase()) }}
class="grid flex-1 place-items-center text-xl font-medium text-gray-500"
>
<div class="flex flex-col items-center justify-center mt-4">
<div>
{{ __('No {0} courses found').format(tab.label.toLowerCase()) }}
</div>
</div>
</div> </div>
</template> </template>
</Tabs> </Tabs>
<div
v-else-if="
!courses.loading &&
(user.data?.is_moderator || user.data?.is_instructor)
"
class="grid grid-cols-3 p-5"
>
<router-link
:to="{
name: 'CourseForm',
params: {
courseName: 'new',
},
}"
>
<div class="bg-gray-50 py-32 px-5 rounded-md">
<div class="flex flex-col items-center text-center space-y-2">
<Plus
class="size-10 stroke-1 text-gray-800 p-1 rounded-full border bg-white"
/>
<div class="font-medium">
{{ __('Create a Course') }}
</div>
<span class="text-gray-700 text-sm leading-4">
{{ __('You can add chapters and lessons to it.') }}
</span>
</div>
</div>
</router-link>
</div>
<div
v-else-if="!courses.loading && !hasCourses"
class="text-center p-5 text-gray-600 mt-52 w-3/4 md:w-1/2 mx-auto space-y-2"
>
<BookOpen class="size-10 mx-auto stroke-1 text-gray-500" />
<div class="text-xl font-medium">
{{ __('No courses found') }}
</div>
<div>
{{
__(
'There are no courses available at the moment. Keep an eye out, fresh learning experiences are on the way soon!'
)
}}
</div>
</div>
</div> </div>
</div> </div>
</template> </template>
@@ -127,13 +167,14 @@ import {
createResource, createResource,
} from 'frappe-ui' } from 'frappe-ui'
import CourseCard from '@/components/CourseCard.vue' import CourseCard from '@/components/CourseCard.vue'
import { Plus, Search } from 'lucide-vue-next' import { BookOpen, Plus, Search } from 'lucide-vue-next'
import { ref, computed, inject, onMounted, watch } from 'vue' import { ref, computed, inject, onMounted, watch } from 'vue'
import { updateDocumentTitle } from '@/utils' import { updateDocumentTitle } from '@/utils'
const user = inject('$user') const user = inject('$user')
const searchQuery = ref('') const searchQuery = ref('')
const currentCategory = ref(null) const currentCategory = ref(null)
const hasCourses = ref(false)
onMounted(() => { onMounted(() => {
let queries = new URLSearchParams(location.search) let queries = new URLSearchParams(location.search)
@@ -223,6 +264,16 @@ const categories = createResource({
}, },
}) })
watch(courses, () => {
if (courses.data) {
Object.keys(courses.data).forEach((section) => {
if (courses.data[section].length) {
hasCourses.value = true
}
})
}
})
watch( watch(
() => currentCategory.value, () => currentCategory.value,
() => { () => {

View File

@@ -301,14 +301,14 @@ const breadcrumbs = computed(() => {
let items = [{ label: 'All Courses', route: { name: 'Courses' } }] let items = [{ label: 'All Courses', route: { name: 'Courses' } }]
items.push({ items.push({
label: lesson?.data?.course_title, label: lesson?.data?.course_title,
route: { name: 'CourseDetail', params: { course: props.courseName } }, route: { name: 'CourseDetail', params: { courseName: props.courseName } },
}) })
items.push({ items.push({
label: lesson?.data?.title, label: lesson?.data?.title,
route: { route: {
name: 'Lesson', name: 'Lesson',
params: { params: {
course: props.courseName, courseName: props.courseName,
chapterNumber: props.chapterNumber, chapterNumber: props.chapterNumber,
lessonNumber: props.lessonNumber, lessonNumber: props.lessonNumber,
}, },

View File

@@ -69,7 +69,7 @@
</div> </div>
</template> </template>
<script setup> <script setup>
import { Breadcrumbs, FormControl, createResource, Button } from 'frappe-ui' import { Breadcrumbs, Button, createResource, FormControl } from 'frappe-ui'
import { import {
computed, computed,
reactive, reactive,

View File

@@ -57,6 +57,15 @@ export function formatNumberIntoCurrency(number, currency) {
return '' return ''
} }
// create a function that formats numbers in thousands to k
export function formatAmount(amount) {
if (amount > 999) {
return (amount / 1000).toFixed(1) + 'k'
}
return amount
}
export function convertToTitleCase(str) { export function convertToTitleCase(str) {
if (!str) { if (!str) {
return '' return ''

View File

@@ -51,7 +51,7 @@ export class Quiz {
app.mount(this.wrapper) app.mount(this.wrapper)
return return
} }
this.wrapper.innerHTML = `<div class='border rounded-md p-10 text-center mb-2'> this.wrapper.innerHTML = `<div class='border rounded-md p-10 text-center bg-gray-50 mb-2'>
<span class="font-medium"> <span class="font-medium">
Quiz: ${quiz} Quiz: ${quiz}
</span> </span>

View File

@@ -1224,10 +1224,10 @@ fraction.js@^4.3.7:
resolved "https://registry.yarnpkg.com/fraction.js/-/fraction.js-4.3.7.tgz#06ca0085157e42fda7f9e726e79fefc4068840f7" resolved "https://registry.yarnpkg.com/fraction.js/-/fraction.js-4.3.7.tgz#06ca0085157e42fda7f9e726e79fefc4068840f7"
integrity sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew== integrity sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==
frappe-ui@^0.1.69: frappe-ui@^0.1.72:
version "0.1.69" version "0.1.72"
resolved "https://registry.yarnpkg.com/frappe-ui/-/frappe-ui-0.1.69.tgz#bfc6d19dff97d2666c36da63f5de62f819539406" resolved "https://registry.yarnpkg.com/frappe-ui/-/frappe-ui-0.1.72.tgz#f5550056ddee7ad4341f2c1825d046404d221820"
integrity sha512-MKHYTcRvmccZwTYlIcmf4OCbJQH5eqKXsq3Cj2lbnmoWuuTh9m7T3AoRKEwOIlZ0mSGCH9yzaF2BINBXGpIJdQ== integrity sha512-XWYKmCjw3ViD+/+tZMUiYqwHFlMGMsVuazOYiN5bKlE+aiheJsnHlOOUyQswYX1Y7jNxuC7gGpSLNg2ZpXA7hA==
dependencies: dependencies:
"@headlessui/vue" "^1.7.14" "@headlessui/vue" "^1.7.14"
"@popperjs/core" "^2.11.2" "@popperjs/core" "^2.11.2"

View File

@@ -110,7 +110,8 @@ doc_events = {
# --------------- # ---------------
scheduler_events = { 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",
], ],
"daily": ["lms.job.doctype.job_opportunity.job_opportunity.update_job_openings"], "daily": ["lms.job.doctype.job_opportunity.job_opportunity.update_job_openings"],
} }

View File

@@ -6,8 +6,9 @@ from frappe.translate import get_all_translations
from frappe import _ from frappe import _
from frappe.query_builder import DocType from frappe.query_builder import DocType
from frappe.query_builder.functions import Count from frappe.query_builder.functions import Count
from frappe.utils import time_diff, now_datetime, get_datetime from frappe.utils import time_diff, now_datetime, get_datetime, flt
from typing import Optional from typing import Optional
from lms.lms.utils import get_average_rating, get_lesson_count
@frappe.whitelist() @frappe.whitelist()
@@ -760,3 +761,23 @@ def get_payment_gateway_details(payment_gateway):
"doctype": doctype, "doctype": doctype,
"docname": docname, "docname": docname,
} }
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},
)

View File

@@ -9,9 +9,8 @@
"engine": "InnoDB", "engine": "InnoDB",
"field_order": [ "field_order": [
"course", "course",
"title",
"column_break_3", "column_break_3",
"description", "title",
"section_break_5", "section_break_5",
"lessons" "lessons"
], ],
@@ -35,11 +34,6 @@
"fieldname": "column_break_3", "fieldname": "column_break_3",
"fieldtype": "Column Break" "fieldtype": "Column Break"
}, },
{
"fieldname": "description",
"fieldtype": "Small Text",
"label": "Description"
},
{ {
"fieldname": "section_break_5", "fieldname": "section_break_5",
"fieldtype": "Section Break" "fieldtype": "Section Break"
@@ -59,7 +53,7 @@
"link_fieldname": "chapter" "link_fieldname": "chapter"
} }
], ],
"modified": "2023-09-29 17:03:58.013819", "modified": "2024-10-29 16:54:20.904683",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "LMS", "module": "LMS",
"name": "Course Chapter", "name": "Course Chapter",

View File

@@ -48,7 +48,12 @@
"certification_section", "certification_section",
"enable_certification", "enable_certification",
"column_break_rxww", "column_break_rxww",
"expiry" "expiry",
"tab_4_tab",
"statistics_section",
"enrollments",
"lessons",
"rating"
], ],
"fields": [ "fields": [
{ {
@@ -249,6 +254,36 @@
"fieldtype": "Link", "fieldtype": "Link",
"label": "Category", "label": "Category",
"options": "LMS Category" "options": "LMS Category"
},
{
"fieldname": "tab_4_tab",
"fieldtype": "Tab Break",
"label": "Statistics"
},
{
"fieldname": "statistics_section",
"fieldtype": "Section Break"
},
{
"default": "0",
"fieldname": "enrollments",
"fieldtype": "Data",
"label": "Enrollments",
"read_only": 1
},
{
"default": "0",
"fieldname": "lessons",
"fieldtype": "Data",
"label": "Lessons",
"read_only": 1
},
{
"default": "0",
"fieldname": "rating",
"fieldtype": "Data",
"label": "Rating",
"read_only": 1
} }
], ],
"is_published_field": "published", "is_published_field": "published",
@@ -275,7 +310,7 @@
} }
], ],
"make_attachments_public": 1, "make_attachments_public": 1,
"modified": "2024-09-21 10:23:58.633912", "modified": "2024-10-30 23:08:31.842860",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "LMS", "module": "LMS",
"name": "LMS Course", "name": "LMS Course",

View File

@@ -187,192 +187,3 @@ def reindex_exercises(doc):
course = frappe.get_doc("LMS Course", course_data["name"]) course = frappe.get_doc("LMS Course", course_data["name"])
course.reindex_exercises() course.reindex_exercises()
frappe.msgprint("All exercises in this course have been re-indexed.") frappe.msgprint("All exercises in this course have been re-indexed.")
@frappe.whitelist(allow_guest=True)
def search_course(text):
courses = frappe.get_all(
"LMS Course",
filters={"published": True},
or_filters={
"title": ["like", f"%{text}%"],
"tags": ["like", f"%{text}%"],
"short_introduction": ["like", f"%{text}%"],
"description": ["like", f"%{text}%"],
},
fields=["name", "title"],
)
return courses
@frappe.whitelist()
def submit_for_review(course):
chapters = frappe.get_all("Chapter Reference", {"parent": course})
if not len(chapters):
return "No Chp"
frappe.db.set_value("LMS Course", course, "status", "Under Review")
return "OK"
@frappe.whitelist()
def save_course(
tags,
title,
short_introduction,
video_link,
description,
course,
published,
upcoming,
image=None,
paid_course=False,
course_price=None,
currency=None,
):
if not can_create_courses(course):
return
if course:
doc = frappe.get_doc("LMS Course", course)
else:
doc = frappe.get_doc({"doctype": "LMS Course"})
doc.update(
{
"title": title,
"short_introduction": short_introduction,
"video_link": video_link,
"image": image,
"description": description,
"tags": tags,
"published": cint(published),
"upcoming": cint(upcoming),
"paid_course": cint(paid_course),
"course_price": course_price,
"currency": currency,
}
)
doc.save(ignore_permissions=True)
return doc.name
@frappe.whitelist()
def save_chapter(course, title, chapter_description, idx, chapter):
if chapter:
doc = frappe.get_doc("Course Chapter", chapter)
else:
doc = frappe.get_doc({"doctype": "Course Chapter"})
doc.update({"course": course, "title": title, "description": chapter_description})
doc.save(ignore_permissions=True)
if chapter:
chapter_reference = frappe.get_doc("Chapter Reference", {"chapter": chapter})
else:
chapter_reference = frappe.get_doc(
{
"doctype": "Chapter Reference",
"parent": course,
"parenttype": "LMS Course",
"parentfield": "chapters",
"idx": idx,
}
)
chapter_reference.update({"chapter": doc.name})
chapter_reference.save(ignore_permissions=True)
return doc.name
@frappe.whitelist()
def save_lesson(
title,
body,
chapter,
preview,
idx,
lesson,
instructor_notes=None,
youtube=None,
quiz_id=None,
question=None,
file_type=None,
):
if lesson:
doc = frappe.get_doc("Course Lesson", lesson)
else:
doc = frappe.get_doc({"doctype": "Course Lesson"})
doc.update(
{
"chapter": chapter,
"title": title,
"body": body,
"instructor_notes": instructor_notes,
"include_in_preview": preview,
"youtube": youtube,
"quiz_id": quiz_id,
"question": question,
"file_type": file_type,
}
)
doc.save(ignore_permissions=True)
if lesson:
lesson_reference = frappe.get_doc("Lesson Reference", {"lesson": lesson})
else:
lesson_reference = frappe.get_doc(
{
"doctype": "Lesson Reference",
"parent": chapter,
"parenttype": "Course Chapter",
"parentfield": "lessons",
"idx": idx,
}
)
lesson_reference.update({"lesson": doc.name})
lesson_reference.save(ignore_permissions=True)
return doc.name
@frappe.whitelist()
def reorder_lesson(old_chapter, old_lesson_array, new_chapter, new_lesson_array):
if old_chapter == new_chapter:
sort_lessons(new_chapter, new_lesson_array)
else:
sort_lessons(old_chapter, old_lesson_array)
sort_lessons(new_chapter, new_lesson_array)
def sort_lessons(chapter, lesson_array):
lesson_array = json.loads(lesson_array)
for les in lesson_array:
ref = frappe.get_all("Lesson Reference", {"lesson": les}, ["name", "idx"])
if ref:
frappe.db.set_value(
"Lesson Reference",
ref[0].name,
{
"parent": chapter,
"idx": lesson_array.index(les) + 1,
},
)
@frappe.whitelist()
def reorder_chapter(chapter_array):
chapter_array = json.loads(chapter_array)
for chap in chapter_array:
ref = frappe.get_all("Chapter Reference", {"chapter": chap}, ["name", "idx"])
if ref:
frappe.db.set_value(
"Chapter Reference",
ref[0].name,
{
"idx": chapter_array.index(chap) + 1,
},
)

View File

@@ -75,7 +75,8 @@
"in_standard_filter": 1, "in_standard_filter": 1,
"label": "Course", "label": "Course",
"options": "LMS Course", "options": "LMS Course",
"reqd": 1 "reqd": 1,
"search_index": 1
}, },
{ {
"fieldname": "current_lesson", "fieldname": "current_lesson",
@@ -126,7 +127,7 @@
], ],
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"links": [], "links": [],
"modified": "2024-05-14 14:50:08.405033", "modified": "2024-10-30 12:44:16.103598",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "LMS", "module": "LMS",
"name": "LMS Enrollment", "name": "LMS Enrollment",

View File

@@ -86,32 +86,32 @@ def get_charts(data):
completed = 0 completed = 0
less_than_hundred = 0 less_than_hundred = 0
less_than_seventy = 0 less_than_seventy_one = 0
less_than_forty = 0 less_than_forty_one = 0
less_than_ten = 0 less_than_eleven = 0
for row in data: for row in data:
if row.progress == 100: if row.progress == 100:
completed += 1 completed += 1
elif row.progress < 100 and row.progress > 70: elif row.progress < 100 and row.progress > 70:
less_than_hundred += 1 less_than_hundred += 1
elif row.progress < 70 and row.progress > 40: elif row.progress < 71 and row.progress > 40:
less_than_seventy += 1 less_than_seventy_one += 1
elif row.progress < 40 and row.progress > 10: elif row.progress < 41 and row.progress > 10:
less_than_forty += 1 less_than_forty_one += 1
elif row.progress < 10: elif row.progress < 11:
less_than_ten += 1 less_than_eleven += 1
charts = { charts = {
"data": { "data": {
"labels": ["0-10", "10-40", "40-70", "70-99", "100"], "labels": ["0-10", "11-40", "41-70", "71-99", "100"],
"datasets": [ "datasets": [
{ {
"name": "Progress (%)", "name": "Progress (%)",
"values": [ "values": [
less_than_ten, less_than_eleven,
less_than_forty, less_than_forty_one,
less_than_seventy, less_than_seventy_one,
less_than_hundred, less_than_hundred,
completed, completed,
], ],

View File

@@ -109,7 +109,7 @@ def get_chapters(course):
chapter_details = frappe.db.get_value( chapter_details = frappe.db.get_value(
"Course Chapter", "Course Chapter",
{"name": chapter.chapter}, {"name": chapter.chapter},
["name", "title", "description"], ["name", "title"],
as_dict=True, as_dict=True,
) )
chapter.update(chapter_details) chapter.update(chapter_details)
@@ -157,11 +157,12 @@ def get_lesson_details(chapter, progress=False):
"file_type", "file_type",
"instructor_notes", "instructor_notes",
"course", "course",
"content",
], ],
as_dict=True, as_dict=True,
) )
lesson_details.number = f"{chapter.idx}.{row.idx}" lesson_details.number = f"{chapter.idx}.{row.idx}"
lesson_details.icon = get_lesson_icon(lesson_details.body) lesson_details.icon = get_lesson_icon(lesson_details.body, lesson_details.content)
if progress: if progress:
lesson_details.is_complete = get_progress(lesson_details.course, lesson_details.name) lesson_details.is_complete = get_progress(lesson_details.course, lesson_details.name)
@@ -170,20 +171,38 @@ def get_lesson_details(chapter, progress=False):
return lessons return lessons
def get_lesson_icon(content): def get_lesson_icon(body, content):
icon = None if content:
macros = find_macros(content) content = json.loads(content)
for block in content.get("blocks"):
if block.get("type") == "upload" and block.get("data").get("file_type").lower() in [
"mp4",
"webm",
"ogg",
"mov",
]:
return "icon-youtube"
if block.get("type") == "embed" and block.get("data").get("service") in [
"youtube",
"vimeo",
]:
return "icon-youtube"
if block.get("type") == "quiz":
return "icon-quiz"
return "icon-list"
macros = find_macros(body)
for macro in macros: for macro in macros:
if macro[0] == "YouTubeVideo" or macro[0] == "Video": if macro[0] == "YouTubeVideo" or macro[0] == "Video":
icon = "icon-youtube" return "icon-youtube"
elif macro[0] == "Quiz": elif macro[0] == "Quiz":
icon = "icon-quiz" return "icon-quiz"
if not icon: return "icon-list"
icon = "icon-list"
return icon
@frappe.whitelist(allow_guest=True) @frappe.whitelist(allow_guest=True)
@@ -1027,23 +1046,13 @@ def get_course_details(course):
"currency", "currency",
"amount_usd", "amount_usd",
"enable_certification", "enable_certification",
"lessons",
"enrollments",
"rating",
], ],
as_dict=1, as_dict=1,
) )
course_details.tags = course_details.tags.split(",") if course_details.tags else [] course_details.tags = course_details.tags.split(",") if course_details.tags else []
course_details.lesson_count = get_lesson_count(course_details.name)
course_details.enrollment_count = frappe.db.count(
"LMS Enrollment", {"course": course_details.name, "member_type": "Student"}
)
course_details.enrollment_count_formatted = format_number(
course_details.enrollment_count
)
avg_rating = get_average_rating(course_details.name) or 0
course_details.avg_rating = flt(
avg_rating, frappe.get_system_settings("float_precision") or 3
)
course_details.instructors = get_instructors(course_details.name) course_details.instructors = get_instructors(course_details.name)
if course_details.paid_course: if course_details.paid_course:
@@ -1092,14 +1101,14 @@ def get_categorized_courses(courses):
): ):
new.append(course) new.append(course)
if course.membership and course.published: if course.membership:
enrolled.append(course) enrolled.append(course)
elif course.is_instructor: elif course.is_instructor:
created.append(course) created.append(course)
categories = [live, enrolled, created] categories = [live, enrolled, created]
for category in categories: for category in categories:
category.sort(key=lambda x: x.enrollment_count, reverse=True) category.sort(key=lambda x: x.enrollments, reverse=True)
live.sort(key=lambda x: x.featured, reverse=True) live.sort(key=lambda x: x.featured, reverse=True)
@@ -1124,7 +1133,7 @@ def get_course_outline(course, progress=False):
chapter_details = frappe.db.get_value( chapter_details = frappe.db.get_value(
"Course Chapter", "Course Chapter",
chapter.chapter, chapter.chapter,
["name", "title", "description"], ["name", "title"],
as_dict=True, as_dict=True,
) )
chapter_details["idx"] = chapter.idx chapter_details["idx"] = chapter.idx

5361
lms/locale/ar.po Normal file

File diff suppressed because it is too large Load Diff

5361
lms/locale/bs.po Normal file

File diff suppressed because it is too large Load Diff

5361
lms/locale/de.po Normal file

File diff suppressed because it is too large Load Diff

5361
lms/locale/eo.po Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

5361
lms/locale/fa.po Normal file

File diff suppressed because it is too large Load Diff

5361
lms/locale/fr.po Normal file

File diff suppressed because it is too large Load Diff

5361
lms/locale/hu.po Normal file

File diff suppressed because it is too large Load Diff

5361
lms/locale/pl.po Normal file

File diff suppressed because it is too large Load Diff

5361
lms/locale/ru.po Normal file

File diff suppressed because it is too large Load Diff

5361
lms/locale/sv.po Normal file

File diff suppressed because it is too large Load Diff

5361
lms/locale/tr.po Normal file

File diff suppressed because it is too large Load Diff

5361
lms/locale/zh.po Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -90,4 +90,5 @@ lms.patches.v1_0.set_published_on
lms.patches.v2_0.fix_progress_percentage lms.patches.v2_0.fix_progress_percentage
lms.patches.v2_0.add_discussion_topic_titles lms.patches.v2_0.add_discussion_topic_titles
lms.patches.v2_0.sidebar_settings lms.patches.v2_0.sidebar_settings
lms.patches.v2_0.delete_certificate_request_notification #18-09-2024 lms.patches.v2_0.delete_certificate_request_notification #18-09-2024
lms.patches.v2_0.add_course_statistics #21-10-2024

View File

@@ -0,0 +1,6 @@
import frappe
from lms.lms.api import update_course_statistics
def execute():
update_course_statistics()