refactor: course list data

This commit is contained in:
Jannat Patel
2024-10-30 22:12:59 +05:30
parent 19b759e9fb
commit 8640ecf9be
18 changed files with 299 additions and 167 deletions

View File

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

View File

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

View File

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

View File

@@ -21,7 +21,7 @@
<div class="space-y-2">
<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')"
>
<span class="leading-5">
@@ -58,9 +58,7 @@
</div>
<div class="space-y-2">
<div
class="flex items-center text-sm font-medium space-x-2 cursor-pointer"
>
<div class="flex items-center text-sm font-medium space-x-2">
<span>
{{ __('What does include in preview mean?') }}
</span>

View File

@@ -32,50 +32,52 @@
</div>
</div>
<div class="mb-4">
<div>
<FileUploader
v-if="!batch.image"
class="mt-4"
: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">
{{ __('Meta Image') }}
</div>
<div class="text-xs text-gray-600 mb-2">
{{ __('Meta Image') }}
</div>
<FileUploader
v-if="!batch.image"
: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 p-2 mr-2">
<FileText class="h-5 w-5 stroke-1.5 text-gray-700" />
<div class="border rounded-md w-fit py-5 px-20">
<Image class="size-5 stroke-1 text-gray-700" />
</div>
<div class="flex flex-col">
<span>
{{ batch.image.file_name }}
</span>
<span class="text-sm text-gray-500 mt-1">
{{ getFileSize(batch.image.file_size) }}
</span>
<div class="ml-4">
<Button @click="openFileSelector">
{{ __('Upload') }}
</Button>
<div class="mt-2 text-gray-600 text-sm">
{{
__(
'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>
<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>
<MultiSelect
v-model="instructors"
doctype="User"
:label="__('Instructors')"
/>
</div>
<div class="mb-4">
<FormControl
@@ -83,6 +85,7 @@
:label="__('Description')"
type="textarea"
class="my-4"
:placeholder="__('Short description of the batch')"
/>
<div>
<label class="block text-sm text-gray-600 mb-1">
@@ -133,6 +136,7 @@
v-model="batch.timezone"
:label="__('Timezone')"
type="text"
:placeholder="__('Example: IST (+5:30)')"
class="mb-4"
/>
</div>
@@ -149,6 +153,7 @@
:label="__('Seat Count')"
type="number"
class="mb-4"
:placeholder="__('Number of seats available')"
/>
<FormControl
v-model="batch.evaluation_end_date"
@@ -228,10 +233,9 @@ import {
createResource,
} from 'frappe-ui'
import Link from '@/components/Controls/Link.vue'
import MultiSelect from '@/components/Controls/MultiSelect.vue'
import { useRouter } from 'vue-router'
import { getFileSize, showToast } from '../utils'
import { X, FileText } from 'lucide-vue-next'
import { showToast } from '../utils'
import { Image } from 'lucide-vue-next'
import { capture } from '@/telemetry'
const router = useRouter()

View File

@@ -40,6 +40,7 @@
{{ __('Loading Batches...') }}
</div>
<Tabs
v-if="hasBatches"
v-model="tabIndex"
:tabs="makeTabs"
tablistClass="overflow-x-visible flex-wrap !gap-3 md:flex-nowrap"
@@ -79,24 +80,63 @@
<BatchCard :batch="batch" />
</router-link>
</div>
<div
v-else
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 v-else class="p-5 italic text-gray-500">
{{ __('No {0} batches').format(tab.label.toLowerCase()) }}
</div>
</template>
</Tabs>
<div
v-else-if="
batches.fetched &&
!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.fetched && !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>
</template>
<script setup>
import {
createListResource,
createResource,
Breadcrumbs,
Button,
@@ -104,13 +144,14 @@ import {
Badge,
Select,
} from 'frappe-ui'
import { Plus } from 'lucide-vue-next'
import { BookOpen, Plus } from 'lucide-vue-next'
import BatchCard from '@/components/BatchCard.vue'
import { inject, ref, computed, onMounted, watch } from 'vue'
import { updateDocumentTitle } from '@/utils'
const user = inject('$user')
const currentCategory = ref(null)
const hasBatches = ref(false)
onMounted(() => {
let queries = new URLSearchParams(location.search)
@@ -119,11 +160,18 @@ onMounted(() => {
}
})
const batches = createListResource({
const batches = createResource({
doctype: 'LMS Batch',
url: 'lms.lms.utils.get_batches',
cache: ['batches', user?.data?.email],
cache: ['batches', user.data?.email],
auto: true,
onSuccess(data) {
Object.keys(data).forEach((section) => {
if (data[section].length) {
hasBatches.value = true
}
})
},
})
const categories = createResource({

View File

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

View File

@@ -27,7 +27,11 @@
<FormControl
v-model="course.short_introduction"
:label="__('Short Introduction')"
:placeholder="__('A one line introduction to the course that appears on the course card')"
:placeholder="
__(
'A one line introduction to the course that appears on the course card'
)
"
class="mb-4"
/>
<div class="mb-4">
@@ -61,10 +65,12 @@
</div>
<div class="ml-4">
<Button @click="openFileSelector">
{{ __("Upload") }}
{{ __('Upload') }}
</Button>
<div class="mt-2 text-gray-600 text-sm">
{{ __("Appears on the course card in the course list") }}
{{
__('Appears on the course card in the course list')
}}
</div>
</div>
</div>
@@ -72,39 +78,29 @@
</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" />
<img
:src="course.course_image.file_url"
class="border rounded-md w-40"
/>
<div class="ml-4">
<Button @click="removeImage()">
{{ __("Remove") }}
{{ __('Remove') }}
</Button>
<div class="mt-2 text-gray-600 text-sm">
{{ __("Appears on the course card in the course list") }}
{{ __('Appears on the course card in the course list') }}
</div>
</div>
</div>
<!-- <div class="flex items-center">
<div class="border rounded-md p-2 mr-2">
<img :src="course.course_image.file_url" class="border rounded-md" />
</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>
<FormControl
v-model="course.video_link"
:label="__('Preview Video')"
:placeholder="__('Paste the youtube link of a short video introducing the course')"
:placeholder="
__(
'Paste the youtube link of a short video introducing the course'
)
"
class="mb-4"
/>
<div class="mb-4">

View File

@@ -8,7 +8,7 @@
:items="[{ label: __('Courses'), route: { name: 'Courses' } }]"
/>
<div class="flex space-x-2 justify-end">
<div class="w-46 md:w-44">
<div class="w-40 md:w-44">
<FormControl
v-if="categories.data?.length"
type="select"
@@ -48,6 +48,7 @@
</header>
<div class="">
<Tabs
v-if="hasCourses"
v-model="tabIndex"
tablistClass="overflow-x-visible flex-wrap !gap-3 md:flex-nowrap"
:tabs="makeTabs"
@@ -101,45 +102,57 @@
<CourseCard :course="course" />
</router-link>
</div>
<div v-else-if="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
class="text-center p-5 text-gray-600 mt-20 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 v-else class="p-5 italic text-gray-500">
{{ __('No {0} courses').format(tab.label.toLowerCase()) }}
</div>
</template>
</Tabs>
<div
v-else-if="
courses.fetched &&
(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.fetched && !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>
</template>
@@ -161,7 +174,7 @@ import { updateDocumentTitle } from '@/utils'
const user = inject('$user')
const searchQuery = ref('')
const currentCategory = ref(null)
const noCoursesFound = ref(false)
const hasCourses = ref(false)
onMounted(() => {
let queries = new URLSearchParams(location.search)
@@ -173,7 +186,14 @@ onMounted(() => {
const courses = createResource({
url: 'lms.lms.utils.get_courses',
cache: ['courses', user.data?.email],
auto: true
auto: true,
onSuccess(data) {
Object.keys(data).forEach((section) => {
if (data[section].length) {
hasCourses.value = true
}
})
},
})
const tabIndex = ref(0)

View File

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

View File

@@ -57,6 +57,15 @@ export function formatNumberIntoCurrency(number, currency) {
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) {
if (!str) {
return ''

View File

@@ -110,7 +110,8 @@ doc_events = {
# ---------------
scheduler_events = {
"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"],
}

View File

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

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

View File

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

View File

@@ -157,7 +157,7 @@ def get_lesson_details(chapter, progress=False):
"file_type",
"instructor_notes",
"course",
"content"
"content",
],
as_dict=True,
)
@@ -176,17 +176,25 @@ def get_lesson_icon(body, 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"]:
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"]:
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:
if macro[0] == "YouTubeVideo" or macro[0] == "Video":
@@ -197,7 +205,6 @@ def get_lesson_icon(body, content):
return "icon-list"
@frappe.whitelist(allow_guest=True)
def get_tags(course):
tags = frappe.db.get_value("LMS Course", course, "tags")
@@ -1039,23 +1046,13 @@ def get_course_details(course):
"currency",
"amount_usd",
"enable_certification",
"lessons",
"enrollments",
"rating",
],
as_dict=1,
)
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)
if course_details.paid_course:
@@ -1111,7 +1108,7 @@ def get_categorized_courses(courses):
categories = [live, enrolled, created]
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)

View File

@@ -90,4 +90,5 @@ lms.patches.v1_0.set_published_on
lms.patches.v2_0.fix_progress_percentage
lms.patches.v2_0.add_discussion_topic_titles
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()