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') }} {{ __('Featured') }}
</Badge> </Badge>
<div <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" class="text-xs bg-white text-gray-800 px-2 py-0.5 rounded-md"
> >
{{ tag }} {{ tag }}

View File

@@ -1,316 +1,325 @@
<template> <template>
<div v-if="courses.data">
<header <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 <Breadcrumbs :items="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>
<router-link <router-link
v-if="user.data?.is_moderator || user.data?.is_instructor" v-if="user.data?.is_moderator"
:to="{ :to="{
name: 'CourseForm', name: 'CourseForm',
params: { params: { courseName: 'new' },
courseName: 'new',
},
}" }"
> >
<Button variant="solid"> <Button variant="solid">
<template #prefix> <template #prefix>
<Plus class="h-4 w-4" /> <Plus class="h-4 w-4 stroke-1.5" />
</template> </template>
{{ __('New') }} {{ __('New') }}
</Button> </Button>
</router-link> </router-link>
</div>
</header> </header>
<div class=""> <div class="p-5 pb-10">
<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 <div
v-if="tab.courses && tab.courses.value.length" class="flex flex-col lg:flex-row space-y-4 lg:space-y-0 lg:items-center justify-between mb-5"
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" >
<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 <router-link
v-for="course in tab.courses.value" v-for="course in courses.data"
:to=" :to="{ name: 'CourseDetail', params: { courseName: course.name } }"
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 },
}
"
> >
<CourseCard :course="course" /> <CourseCard :course="course" />
</router-link> </router-link>
</div> </div>
<div v-else class="p-5 italic text-ink-gray-4">
{{ __('No {0} courses').format(tab.label.toLowerCase()) }}
</div>
</template>
</Tabs>
<div <div
v-else-if=" v-else-if="!courses.list.loading"
!courses.loading && class="flex flex-col items-center justify-center text-sm text-ink-gray-5 italic mt-48"
(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"
> >
<BookOpen class="size-10 mx-auto stroke-1 text-ink-gray-4" /> <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') }} {{ __('No courses found') }}
</div> </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> </div>
<div
v-if="!courses.list.loading && courses.hasNextPage"
class="flex justify-center mt-5"
>
<Button @click="courses.next()">
{{ __('Load More') }}
</Button>
</div> </div>
</div> </div>
</template> </template>
<script setup> <script setup>
import { import {
Badge,
Breadcrumbs, Breadcrumbs,
Button, Button,
call, createListResource,
createResource,
FormControl, FormControl,
Tabs, Select,
TabButtons,
} from 'frappe-ui' } from 'frappe-ui'
import CourseCard from '@/components/CourseCard.vue' import { computed, inject, onMounted, ref, watch } from 'vue'
import { BookOpen, Plus, Search } from 'lucide-vue-next' import { BookOpen, Plus } from 'lucide-vue-next'
import { ref, computed, inject, onMounted, watch } from 'vue'
import { updateDocumentTitle } from '@/utils' import { updateDocumentTitle } from '@/utils'
import { useRouter } from 'vue-router' import CourseCard from '@/components/CourseCard.vue'
import { useSettings } from '@/stores/settings'
const user = inject('$user') 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 currentCategory = ref(null)
const hasCourses = ref(false) const title = ref('')
const router = useRouter() const certification = ref(false)
const settings = useSettings() const filters = ref({})
const currentTab = ref('Live')
onMounted(() => { onMounted(() => {
checkLearningPath() setFiltersFromQuery()
let queries = new URLSearchParams(location.search) updateCourses()
if (queries.has('category')) { categories.value = [
currentCategory.value = queries.get('category') {
} label: '',
value: null,
},
]
}) })
const checkLearningPath = () => { const setFiltersFromQuery = () => {
if ( let queries = new URLSearchParams(location.search)
settings.learningPaths.data && title.value = queries.get('title') || ''
(!user.data?.is_moderator || !user.data?.is_instructor) currentCategory.value = queries.get('category') || null
) { certification.value = queries.get('certification') || false
router.push({ name: 'Programs' }) }
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({ const updateTitleFilter = () => {
url: 'lms.lms.utils.get_courses', if (title.value) {
cache: ['courses', user.data?.email], filters.value['title'] = ['like', `%${title.value}%`]
auto: true, } 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) const courseType = computed(() => {
let tabs let types = [
{ label: __(''), value: null },
const makeTabs = computed(() => { { label: __('New'), value: 'New' },
tabs = [] { label: __('Upcoming'), value: 'Upcoming' },
addToTabs('Live') ]
addToTabs('New') if (user.data?.is_student) {
addToTabs('Upcoming') types.push({ label: __('Enrolled'), value: 'Enrolled' })
} else {
if (user.data) { types.push({ label: __('Created'), value: 'Created' })
addToTabs('Enrolled')
if (
user.data.is_moderator ||
user.data.is_instructor ||
courses.data?.created?.length
) {
addToTabs('Created')
} }
return types
})
if (user.data.is_moderator) { const courseTabs = computed(() => {
addToTabs('Under Review') let tabs = [
} {
label: __('Live'),
},
{
label: __('New'),
},
{
label: __('Upcoming'),
},
]
if (user.data?.is_student) {
tabs.push({ label: __('Enrolled') })
} else {
tabs.push({ label: __('Created') })
} }
return tabs return tabs
}) })
const addToTabs = (label) => { const breadcrumbs = computed(() => [
let courses = getCourses(label.toLowerCase().split(' ').join('_')) {
tabs.push({ label: __('Courses'),
label, route: { name: 'Courses' },
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,
}, },
} ])
},
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(() => { const pageMeta = computed(() => {
return { return {
title: 'Courses', title: 'Courses',
description: 'All Courses divided by categories', description: 'All published courses.',
} }
}) })

View File

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

View File

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

View File

@@ -985,17 +985,145 @@ def change_currency(amount, currency, country=None):
@frappe.whitelist(allow_guest=True) @frappe.whitelist(allow_guest=True)
def get_courses(): def get_courses(filters=None, start=0, page_length=20):
"""Returns the list of courses.""" """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 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) @frappe.whitelist(allow_guest=True)
def get_course_details(course): def get_course_details(course):
course_details = frappe.db.get_value( course_details = frappe.db.get_value(