Merge pull request #1357 from pateljannat/course-certification-filter

refactor: course list fetching and filters
This commit is contained in:
Jannat Patel
2025-03-04 17:27:01 +05:30
committed by GitHub
5 changed files with 423 additions and 285 deletions

View File

@@ -16,7 +16,8 @@
{{ __('Featured') }}
</Badge>
<div
v-for="tag in course.tags"
v-if="course.tags"
v-for="tag in course.tags?.split(', ')"
class="text-xs bg-white text-gray-800 px-2 py-0.5 rounded-md"
>
{{ tag }}

View File

@@ -1,316 +1,325 @@
<template>
<div v-if="courses.data">
<header
class="sticky top-0 z-10 flex items-center justify-between border-b bg-surface-white px-3 py-2.5 sm:px-5"
class="sticky flex items-center justify-between top-0 z-10 border-b bg-surface-white px-3 py-2.5 sm:px-5"
>
<Breadcrumbs
class="h-7"
:items="[{ label: __('Courses'), route: { name: 'Courses' } }]"
/>
<div class="flex space-x-2 justify-end">
<div class="w-40 md:w-44">
<FormControl
v-if="categories.data?.length"
type="select"
v-model="currentCategory"
:options="categories.data"
:placeholder="__('Category')"
/>
</div>
<div class="w-28 md:w-36">
<FormControl
type="text"
placeholder="Search"
v-model="searchQuery"
@input="courses.reload()"
>
<template #prefix>
<Search
class="w-4 h-4 stroke-1.5 text-ink-gray-5"
name="search"
/>
</template>
</FormControl>
</div>
<Breadcrumbs :items="breadcrumbs" />
<router-link
v-if="user.data?.is_moderator || user.data?.is_instructor"
v-if="user.data?.is_moderator"
:to="{
name: 'CourseForm',
params: {
courseName: 'new',
},
params: { courseName: 'new' },
}"
>
<Button variant="solid">
<template #prefix>
<Plus class="h-4 w-4" />
<Plus class="h-4 w-4 stroke-1.5" />
</template>
{{ __('New') }}
</Button>
</router-link>
</div>
</header>
<div class="">
<Tabs
v-if="hasCourses"
as="div"
v-model="tabIndex"
tablistClass="overflow-x-visible flex-wrap !gap-3 md:flex-nowrap"
:tabs="makeTabs"
>
<template #tab="{ tab, selected }">
<div>
<button
class="group -mb-px flex items-center gap-2 overflow-hidden border-b border-transparent py-2.5 text-base text-ink-gray-5 duration-300 ease-in-out hover:border-outline-gray-3 hover:text-ink-gray-9"
:class="{ 'text-ink-gray-9': selected }"
>
<component v-if="tab.icon" :is="tab.icon" class="h-5" />
{{ __(tab.label) }}
<Badge theme="gray">
{{ tab.count }}
</Badge>
</button>
</div>
</template>
<template #tab-panel="{ tab }">
<div class="p-5 pb-10">
<div
v-if="tab.courses && tab.courses.value.length"
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4 3xl:grid-cols-5 gap-7 my-5 mx-5"
class="flex flex-col lg:flex-row space-y-4 lg:space-y-0 lg:items-center justify-between mb-5"
>
<div class="text-lg font-semibold">
{{ __('All Courses') }}
</div>
<div
class="flex flex-col space-y-2 lg:space-y-0 lg:flex-row lg:items-center lg:space-x-4"
>
<TabButtons
v-if="user.data"
:buttons="courseTabs"
v-model="currentTab"
/>
<FormControl
v-model="certification"
:label="__('Certification')"
type="checkbox"
@change="updateCourses()"
/>
<div class="grid grid-cols-2 gap-2">
<FormControl
v-model="title"
:placeholder="__('Search by Title')"
type="text"
class="min-w-40 lg:min-w-0 lg:w-32 xl:w-40"
@input="updateCourses()"
/>
<div class="min-w-40 lg:min-w-0 lg:w-32 xl:w-40">
<Select
v-if="categories.length"
v-model="currentCategory"
:options="categories"
:placeholder="__('Category')"
@change="updateCourses()"
/>
</div>
</div>
</div>
</div>
<div
v-if="courses.data?.length"
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4 gap-5"
>
<router-link
v-for="course in tab.courses.value"
:to="
course.membership && course.current_lesson
? {
name: 'Lesson',
params: {
courseName: course.name,
chapterNumber: course.current_lesson.split('-')[0],
lessonNumber: course.current_lesson.split('-')[1],
},
}
: course.membership
? {
name: 'Lesson',
params: {
courseName: course.name,
chapterNumber: 1,
lessonNumber: 1,
},
}
: {
name: 'CourseDetail',
params: { courseName: course.name },
}
"
v-for="course in courses.data"
:to="{ name: 'CourseDetail', params: { courseName: course.name } }"
>
<CourseCard :course="course" />
</router-link>
</div>
<div v-else class="p-5 italic text-ink-gray-4">
{{ __('No {0} courses').format(tab.label.toLowerCase()) }}
</div>
</template>
</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-surface-menu-bar 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-ink-gray-8 p-1 rounded-full border bg-surface-white"
/>
<div class="font-medium">
{{ __('Create a Course') }}
</div>
<span class="text-ink-gray-7 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-ink-gray-5 mt-52 w-3/4 md:w-1/2 mx-auto space-y-2"
v-else-if="!courses.list.loading"
class="flex flex-col items-center justify-center text-sm text-ink-gray-5 italic mt-48"
>
<BookOpen class="size-10 mx-auto stroke-1 text-ink-gray-4" />
<div class="text-xl font-medium">
<div class="text-lg font-medium mb-1">
{{ __('No courses found') }}
</div>
<div class="leading-5">
<div class="leading-5 w-2/5 text-center">
{{
__(
'There are no courses available at the moment. Keep an eye out, fresh learning experiences are on the way soon!'
'There are no courses matching the criteria. Keep an eye out, fresh learning experiences are on the way soon!'
)
}}
</div>
</div>
<div
v-if="!courses.list.loading && courses.hasNextPage"
class="flex justify-center mt-5"
>
<Button @click="courses.next()">
{{ __('Load More') }}
</Button>
</div>
</div>
</template>
<script setup>
import {
Badge,
Breadcrumbs,
Button,
call,
createResource,
createListResource,
FormControl,
Tabs,
Select,
TabButtons,
} from 'frappe-ui'
import CourseCard from '@/components/CourseCard.vue'
import { BookOpen, Plus, Search } from 'lucide-vue-next'
import { ref, computed, inject, onMounted, watch } from 'vue'
import { computed, inject, onMounted, ref, watch } from 'vue'
import { BookOpen, Plus } from 'lucide-vue-next'
import { updateDocumentTitle } from '@/utils'
import { useRouter } from 'vue-router'
import { useSettings } from '@/stores/settings'
import CourseCard from '@/components/CourseCard.vue'
const user = inject('$user')
const searchQuery = ref('')
const dayjs = inject('$dayjs')
const start = ref(0)
const pageLength = ref(30)
const categories = ref([])
const currentCategory = ref(null)
const hasCourses = ref(false)
const router = useRouter()
const settings = useSettings()
const title = ref('')
const certification = ref(false)
const filters = ref({})
const currentTab = ref('Live')
onMounted(() => {
checkLearningPath()
let queries = new URLSearchParams(location.search)
if (queries.has('category')) {
currentCategory.value = queries.get('category')
}
setFiltersFromQuery()
updateCourses()
categories.value = [
{
label: '',
value: null,
},
]
})
const checkLearningPath = () => {
if (
settings.learningPaths.data &&
(!user.data?.is_moderator || !user.data?.is_instructor)
) {
router.push({ name: 'Programs' })
const setFiltersFromQuery = () => {
let queries = new URLSearchParams(location.search)
title.value = queries.get('title') || ''
currentCategory.value = queries.get('category') || null
certification.value = queries.get('certification') || false
}
const courses = createListResource({
doctype: 'LMS Course',
url: 'lms.lms.utils.get_courses',
cache: ['courses', user.data?.name],
pageLength: pageLength.value,
start: start.value,
onSuccess(data) {
let allCategories = data.map((course) => course.category)
allCategories = allCategories.filter(
(category, index) => allCategories.indexOf(category) === index && category
)
if (categories.value.length <= allCategories.length) {
updateCategories(data)
}
},
})
const updateCourses = () => {
updateFilters()
courses.update({
filters: filters.value,
})
courses.reload()
}
const updateFilters = () => {
updateCategoryFilter()
updateTitleFilter()
updateCertificationFilter()
updateTabFilter()
updateStudentFilter()
setQueryParams()
}
const updateCategoryFilter = () => {
if (currentCategory.value) {
filters.value['category'] = currentCategory.value
} else {
delete filters.value['category']
}
}
const courses = createResource({
url: 'lms.lms.utils.get_courses',
cache: ['courses', user.data?.email],
auto: true,
const updateTitleFilter = () => {
if (title.value) {
filters.value['title'] = ['like', `%${title.value}%`]
} else {
delete filters.value['title']
}
}
const updateCertificationFilter = () => {
if (certification.value) {
filters.value['certification'] = 1
} else {
delete filters.value['certification']
}
}
const updateTabFilter = () => {
if (!user.data) {
return
}
delete filters.value['live']
delete filters.value['created']
delete filters.value['published_on']
delete filters.value['upcoming']
if (currentTab.value == 'Enrolled' && user.data?.is_student) {
filters.value['enrolled'] = 1
delete filters.value['published']
} else {
delete filters.value['published']
delete filters.value['enrolled']
if (currentTab.value == 'Live') {
filters.value['published'] = 1
filters.value['upcoming'] = 0
filters.value['live'] = 1
} else if (currentTab.value == 'Upcoming') {
filters.value['upcoming'] = 1
filters.value['published'] = 1
} else if (currentTab.value == 'New') {
filters.value['published'] = 1
filters.value['published_on'] = [
'>=',
dayjs().add(-3, 'month').format('YYYY-MM-DD'),
]
} else if (currentTab.value == 'Created') {
filters.value['created'] = 1
}
}
}
const updateStudentFilter = () => {
if (!user.data || (user.data?.is_student && currentTab.value != 'Enrolled')) {
filters.value['published'] = 1
}
}
const setQueryParams = () => {
let queries = new URLSearchParams(location.search)
let filterKeys = {
title: title.value,
category: currentCategory.value,
certification: certification.value,
}
Object.keys(filterKeys).forEach((key) => {
if (filterKeys[key]) {
queries.set(key, filterKeys[key])
} else {
queries.delete(key)
}
})
history.replaceState({}, '', `${location.pathname}?${queries.toString()}`)
}
const updateCategories = (data) => {
data.forEach((course) => {
if (
course.category &&
!categories.value.find((category) => category.value === course.category)
)
categories.value.push({
label: course.category,
value: course.category,
})
})
}
watch(currentTab, () => {
updateCourses()
})
const tabIndex = ref(0)
let tabs
const makeTabs = computed(() => {
tabs = []
addToTabs('Live')
addToTabs('New')
addToTabs('Upcoming')
if (user.data) {
addToTabs('Enrolled')
if (
user.data.is_moderator ||
user.data.is_instructor ||
courses.data?.created?.length
) {
addToTabs('Created')
const courseType = computed(() => {
let types = [
{ label: __(''), value: null },
{ label: __('New'), value: 'New' },
{ label: __('Upcoming'), value: 'Upcoming' },
]
if (user.data?.is_student) {
types.push({ label: __('Enrolled'), value: 'Enrolled' })
} else {
types.push({ label: __('Created'), value: 'Created' })
}
return types
})
if (user.data.is_moderator) {
addToTabs('Under Review')
}
const courseTabs = computed(() => {
let tabs = [
{
label: __('Live'),
},
{
label: __('New'),
},
{
label: __('Upcoming'),
},
]
if (user.data?.is_student) {
tabs.push({ label: __('Enrolled') })
} else {
tabs.push({ label: __('Created') })
}
return tabs
})
const addToTabs = (label) => {
let courses = getCourses(label.toLowerCase().split(' ').join('_'))
tabs.push({
label,
courses: computed(() => courses),
count: computed(() => courses.length),
})
}
const getCourses = (type) => {
let courseList = courses.data[type]
if (searchQuery.value) {
let query = searchQuery.value.toLowerCase()
courseList = courseList.filter(
(course) =>
course.title.toLowerCase().includes(query) ||
course.short_introduction.toLowerCase().includes(query) ||
course.tags.filter((tag) => tag.toLowerCase().includes(query)).length
)
}
if (currentCategory.value && currentCategory.value != '') {
courseList = courseList.filter(
(course) => course.category == currentCategory.value
)
}
return courseList
}
const categories = createResource({
url: 'lms.lms.api.get_categories',
makeParams() {
return {
doctype: 'LMS Course',
filters: {
published: 1,
const breadcrumbs = computed(() => [
{
label: __('Courses'),
route: { name: 'Courses' },
},
}
},
cache: ['courseCategories'],
auto: true,
transform(data) {
data.unshift({
label: '',
value: null,
})
},
})
watch(courses, () => {
if (courses.data) {
Object.keys(courses.data).forEach((section) => {
if (courses.data[section].length) {
hasCourses.value = true
}
})
}
})
watch(
() => currentCategory.value,
() => {
let queries = new URLSearchParams(location.search)
if (currentCategory.value) {
queries.set('category', currentCategory.value)
} else {
queries.delete('category')
}
history.pushState(null, '', `${location.pathname}?${queries.toString()}`)
}
)
])
const pageMeta = computed(() => {
return {
title: 'Courses',
description: 'All Courses divided by categories',
description: 'All published courses.',
}
})

View File

@@ -12,7 +12,7 @@ module.exports = {
1.5: '1.5',
},
screens: {
'2xl': '1536px',
'2xl': '1600px',
'3xl': '1920px',
},
},

View File

@@ -242,14 +242,14 @@
{
"default": "0",
"fieldname": "enrollments",
"fieldtype": "Data",
"fieldtype": "Int",
"label": "Enrollments",
"read_only": 1
},
{
"default": "0",
"fieldname": "lessons",
"fieldtype": "Data",
"fieldtype": "Int",
"label": "Lessons",
"read_only": 1
},
@@ -298,7 +298,7 @@
}
],
"make_attachments_public": 1,
"modified": "2025-02-24 11:50:58.325804",
"modified": "2025-03-04 15:43:25.151554",
"modified_by": "Administrator",
"module": "LMS",
"name": "LMS Course",

View File

@@ -985,17 +985,145 @@ def change_currency(amount, currency, country=None):
@frappe.whitelist(allow_guest=True)
def get_courses():
def get_courses(filters=None, start=0, page_length=20):
"""Returns the list of courses."""
courses = []
course_list = frappe.get_all("LMS Course", pluck="name")
for course in course_list:
courses.append(get_course_details(course))
courses = get_categorized_courses(courses)
if not filters:
filters = {}
filters, or_filters, show_featured = update_course_filters(filters)
fields = get_course_fields()
courses = frappe.get_all(
"LMS Course",
filters=filters,
fields=fields,
or_filters=or_filters,
order_by="enrollments desc",
start=start,
page_length=page_length,
)
if show_featured:
courses = get_featured_courses(filters, or_filters, fields) + courses
courses = get_enrollment_details(courses)
courses = get_course_card_details(courses)
return courses
def get_course_card_details(courses):
for course in courses:
course.instructors = get_instructors(course.name)
if course.paid_course and course.published == 1:
course.amount, course.currency = check_multicurrency(
course.course_price, course.currency, None, course.amount_usd
)
course.price = fmt_money(course.amount, 0, course.currency)
return courses
def get_course_or_filters(filters):
or_filters = {}
or_filters.update({"title": filters.get("title")})
or_filters.update({"short_introduction": filters.get("title")})
or_filters.update({"description": filters.get("title")})
or_filters.update({"tags": filters.get("title")})
return or_filters
def update_course_filters(filters):
or_filters = {}
show_featured = False
if filters.get("title"):
or_filters = get_course_or_filters(filters)
del filters["title"]
if filters.get("enrolled"):
enrolled_courses = frappe.get_all(
"LMS Enrollment", {"member": frappe.session.user}, pluck="course"
)
filters.update({"name": ["in", enrolled_courses]})
del filters["enrolled"]
if filters.get("created"):
created_courses = frappe.get_all(
"Course Instructor", {"instructor": frappe.session.user}, pluck="parent"
)
filters.update({"name": ["in", created_courses]})
del filters["created"]
if filters.get("live"):
filters.update({"featured": 0})
show_featured = True
del filters["live"]
if filters.get("certification"):
or_filters.update({"enable_certification": 1})
or_filters.update({"paid_certificate": 1})
del filters["certification"]
return filters, or_filters, show_featured
def get_enrollment_details(courses):
for course in courses:
filters = {
"course": course.name,
"member": frappe.session.user,
}
if frappe.db.exists("LMS Enrollment", filters):
course.membership = frappe.db.get_value(
"LMS Enrollment",
filters,
["name", "course", "current_lesson", "progress", "member"],
as_dict=1,
)
return courses
def get_featured_courses(filters, or_filters, fields):
filters.update({"featured": 1})
featured_courses = frappe.get_all(
"LMS Course",
filters=filters,
fields=fields,
or_filters=or_filters,
order_by="enrollments desc",
)
return featured_courses
def get_course_fields():
return [
"name",
"title",
"tags",
"image",
"short_introduction",
"published",
"upcoming",
"featured",
"disable_self_learning",
"published_on",
"category",
"status",
"paid_course",
"paid_certificate",
"course_price",
"currency",
"amount_usd",
"enable_certification",
"lessons",
"enrollments",
"rating",
]
@frappe.whitelist(allow_guest=True)
def get_course_details(course):
course_details = frappe.db.get_value(