feat: reorder courses and students view for programs

This commit is contained in:
Jannat Patel
2024-11-20 19:32:49 +05:30
parent e1a78382c3
commit 582c7af12d
12 changed files with 575 additions and 137 deletions

View File

@@ -92,7 +92,7 @@
{{ option.label }} {{ option.label }}
</div> </div>
<div <div
v-if="option.label != option.description" v-if="option.description"
class="text-xs text-gray-700" class="text-xs text-gray-700"
v-html="option.description" v-html="option.description"
></div> ></div>

View File

@@ -44,6 +44,7 @@
</div> </div>
</template> </template>
</Autocomplete> </Autocomplete>
<p v-if="description" class="text-sm text-gray-600">{{ description }}</p>
</div> </div>
</template> </template>
@@ -67,6 +68,10 @@ const props = defineProps({
type: String, type: String,
default: '', default: '',
}, },
description: {
type: String,
default: '',
},
}) })
const emit = defineEmits(['update:modelValue', 'change']) const emit = defineEmits(['update:modelValue', 'change'])
@@ -118,7 +123,7 @@ const options = createResource({
transform: (data) => { transform: (data) => {
return data.map((option) => { return data.map((option) => {
return { return {
label: option.value, label: option.label || option.value,
value: option.value, value: option.value,
description: option.description, description: option.description,
} }

View File

@@ -115,21 +115,21 @@ const tabsStructure = computed(() => {
label: 'Enable Learning Paths', label: 'Enable Learning Paths',
name: 'enable_learning_paths', name: 'enable_learning_paths',
description: description:
'This will change the default flow of the system and enforce students to go through programs assigned to them in the correct order.', 'This will enforce students to go through programs assigned to them in the correct order.',
type: 'checkbox', type: 'checkbox',
}, },
{ {
label: 'Send calendar invite for evaluations', label: 'Send calendar invite for evaluations',
name: 'send_calendar_invite_for_evaluations', name: 'send_calendar_invite_for_evaluations',
description: description:
'If enabled and Google Calendar of the evaluator is set in the system, students will receive calendar invites to remind them of their evaluations.', 'If enabled, it sends google calendar invite to the student for evaluations.',
type: 'checkbox', type: 'checkbox',
}, },
{ {
label: 'Unsplash Access Key', label: 'Unsplash Access Key',
name: 'unsplash_access_key', name: 'unsplash_access_key',
description: description:
'Optional. If this is set, students can pick a cover image from the unsplash library for their profile page. Refer the docs to know more https://unsplash.com/documentation#getting-started.', 'Optional. If this is set, students can pick a cover image from the unsplash library for their profile page. https://unsplash.com/documentation#getting-started.',
type: 'text', type: 'text',
}, },
], ],

View File

@@ -160,30 +160,45 @@
<script setup> <script setup>
import { import {
Breadcrumbs,
Tabs,
Badge, Badge,
Breadcrumbs,
Button, Button,
FormControl, call,
createResource, createResource,
FormControl,
Tabs,
} from 'frappe-ui' } from 'frappe-ui'
import CourseCard from '@/components/CourseCard.vue' import CourseCard from '@/components/CourseCard.vue'
import { BookOpen, 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'
import { useRouter } from 'vue-router'
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) const hasCourses = ref(false)
const router = useRouter()
onMounted(() => { onMounted(() => {
checkLearningPath()
let queries = new URLSearchParams(location.search) let queries = new URLSearchParams(location.search)
if (queries.has('category')) { if (queries.has('category')) {
currentCategory.value = queries.get('category') currentCategory.value = queries.get('category')
} }
}) })
const checkLearningPath = () => {
call('frappe.client.get_single_value', {
doctype: 'LMS Settings',
field: 'enable_learning_paths',
}).then((res) => {
if (res && !user.data?.is_moderator && !user.data?.is_instructor) {
router.push({ name: 'Programs' })
}
})
}
const courses = createResource({ const courses = createResource({
url: 'lms.lms.utils.get_courses', url: 'lms.lms.utils.get_courses',
cache: ['courses', user.data?.email], cache: ['courses', user.data?.email],

View File

@@ -7,48 +7,195 @@
{{ __('Save') }} {{ __('Save') }}
</Button> </Button>
</header> </header>
<div v-if="program.doc" class="w-1/2 mx-auto pt-10 space-y-5"> <div v-if="program.doc" class="pt-5 px-5 w-3/4 mx-auto space-y-10">
<FormControl :label="__('Title')" v-model="program.doc.title" /> <FormControl v-model="program.doc.title" :label="__('Title')" />
<!-- Courses -->
<div> <div>
<div> <div class="flex items-center justify-between mb-2">
<div class="font-semibold"> <div class="text-lg font-semibold">
{{ __('Program Courses') }} {{ __('Program Courses') }}
</div> </div>
<div></div> <Button
@click="
() => {
currentForm = 'course'
showDialog = true
}
"
>
<template #prefix>
<Plus class="w-4 h-4" />
</template>
{{ __('Add') }}
</Button>
</div> </div>
<ListView <ListView
v-if="program.doc.program_courses.length"
:columns="courseColumns" :columns="courseColumns"
:rows="program.doc.program_courses" :rows="program.doc.program_courses"
row-key="name" row-key="name"
:options="{ showTooltip: false, selectable: false }" :options="{
showTooltip: false,
}"
> >
<ListHeader <ListHeader
class="mb-2 grid items-center space-x-4 rounded bg-gray-100 p-2" class="mb-2 grid items-center space-x-4 rounded bg-gray-100 p-2"
> >
<ListHeaderItem :item="item" v-for="item in courseColumns"> <ListHeaderItem :item="item" v-for="item in courseColumns" />
</ListHeaderItem>
</ListHeader> </ListHeader>
<ListRows> <ListRows>
<ListRow :row="row" /> <Draggable
:list="program.doc.program_courses"
item-key="name"
group="items"
@end="updateOrder"
>
<template #item="{ element: row }">
<ListRow :row="row" />
</template>
</Draggable>
</ListRows> </ListRows>
<ListSelectBanner>
<template #actions="{ unselectAll, selections }">
<div class="flex gap-2">
<Button
variant="ghost"
@click="remove(selections, unselectAll, 'program_courses')"
>
<Trash2 class="h-4 w-4 stroke-1.5" />
</Button>
</div>
</template>
</ListSelectBanner>
</ListView>
</div>
<!-- Members -->
<div>
<div class="flex items-center justify-between mb-2">
<div class="text-lg font-semibold">
{{ __('Program Members') }}
</div>
<Button
@click="
() => {
currentForm = 'member'
showDialog = true
}
"
>
<template #prefix>
<Plus class="w-4 h-4" />
</template>
{{ __('Add') }}
</Button>
</div>
<ListView
:columns="memberColumns"
:rows="program.doc.program_members"
row-key="name"
:options="{
showTooltip: false,
}"
>
<ListHeader
class="mb-2 grid items-center space-x-4 rounded bg-gray-100 p-2"
>
<ListHeaderItem :item="item" v-for="item in memberColumns" />
</ListHeader>
<ListRows>
<ListRow :row="row" v-for="row in program.doc.program_members" />
</ListRows>
<ListSelectBanner>
<template #actions="{ unselectAll, selections }">
<div class="flex gap-2">
<Button
variant="ghost"
@click="remove(selections, unselectAll, 'program_members')"
>
<Trash2 class="h-4 w-4 stroke-1.5" />
</Button>
</div>
</template>
</ListSelectBanner>
</ListView> </ListView>
</div> </div>
</div> </div>
<Dialog
v-model="showDialog"
:options="{
title:
currentForm == 'course'
? __('New Program Course')
: __('New Program Member'),
actions: [
{
label: __('Add'),
variant: 'solid',
onClick: () =>
currentForm == 'course'
? addProgramCourse(close)
: addProgramMember(close),
},
],
}"
>
<template #body-content>
<Link
v-if="currentForm == 'course'"
v-model="course"
doctype="LMS Course"
:filters="{
disable_self_learning: 1,
}"
:label="__('Program Course')"
:description="
__(
'Only courses for which self learning is disabled can be added to program.'
)
"
/>
<Link
v-if="currentForm == 'member'"
v-model="member"
doctype="User"
:filters="{
ignore_user_type: 1,
}"
:label="__('Program Member')"
/>
</template>
</Dialog>
</template> </template>
<script setup> <script setup>
import { import {
Breadcrumbs, Breadcrumbs,
Button, Button,
call,
createDocumentResource, createDocumentResource,
Dialog,
FormControl, FormControl,
ListView, ListView,
ListRows, ListRows,
ListRow, ListRow,
ListHeader, ListHeader,
ListHeaderItem, ListHeaderItem,
ListSelectBanner,
} from 'frappe-ui' } from 'frappe-ui'
import { computed } from 'vue' import { computed, ref } from 'vue'
import { Plus, Trash2 } from 'lucide-vue-next'
import Link from '@/components/Controls/Link.vue'
import { showToast } from '@/utils/'
import Draggable from 'vuedraggable'
const showDialog = ref(false)
const currentForm = ref(null)
const course = ref(null)
const member = ref(null)
const props = defineProps({ const props = defineProps({
programName: { programName: {
@@ -66,19 +213,135 @@ const program = createDocumentResource({
console.log(program) console.log(program)
const courseColumns = computed(() => [ const addProgramCourse = () => {
{ program.setValue.submit(
label: 'Course', {
key: 'course_title', program_courses: [
width: '2', ...program.doc.program_courses,
}, { course: course.value },
]) ],
},
{
onSuccess(data) {
showDialog.value = false
course.value = null
showToast(__('Success'), __('Course added to program'), 'check')
program.reload()
},
onError(err) {
showToast('Error', err.messages?.[0] || err, 'x')
},
}
)
}
const addProgramMember = () => {
program.setValue.submit(
{
program_members: [
...program.doc.program_members,
{ member: member.value },
],
},
{
onSuccess(data) {
showDialog.value = false
member.value = null
showToast(__('Success'), __('Member added to program'), 'check')
program.reload()
},
onError(err) {
showToast('Error', err.messages?.[0] || err, 'x')
},
}
)
}
const remove = (selections, unselectAll, doctype) => {
selections = Array.from(selections)
program.setValue.submit(
{
[doctype]: program.doc[doctype].filter(
(row) => !selections.includes(row.name)
),
},
{
onSuccess(data) {
unselectAll()
showToast(__('Success'), __('Items removed successfully'), 'check')
program.reload()
},
onError(err) {
showToast('Error', err.messages?.[0] || err, 'x')
},
}
)
}
const updateOrder = (e) => {
let sourceIdx = e.from.dataset.idx
let targetIdx = e.to.dataset.idx
let courses = program.doc.program_courses
courses.splice(targetIdx, 0, courses.splice(sourceIdx, 1)[0])
courses.forEach((course, index) => {
course.idx = index + 1
})
console.log(courses)
program.setValue.submit(
{
program_courses: courses,
},
{
onSuccess(data) {
showToast(__('Success'), __('Course moved successfully'), 'check')
program.reload()
},
onError(err) {
showToast('Error', err.messages?.[0] || err, 'x')
},
}
)
}
const courseColumns = computed(() => {
return [
{
label: 'Title',
key: 'course_title',
width: 3,
},
{
label: 'ID',
key: 'course',
width: 3,
},
]
})
const memberColumns = computed(() => {
return [
{
label: 'Member',
key: 'member',
width: 3,
align: 'left',
},
{
label: 'Full Name',
key: 'full_name',
width: 3,
align: 'left',
},
]
})
const breadbrumbs = computed(() => { const breadbrumbs = computed(() => {
return [ return [
{ {
label: 'Programs', label: 'Programs',
to: { name: 'Programs' }, route: { name: 'Programs' },
}, },
{ {
label: props.programName === 'new' ? 'New Program' : props.programName, label: props.programName === 'new' ? 'New Program' : props.programName,

View File

@@ -1,59 +1,98 @@
<template> <template>
<header <header
class="sticky top-0 z-10 flex flex-col md:flex-row md:items-center justify-between border-b bg-white px-3 py-2.5 sm:px-5" class="sticky top-0 z-10 flex items-center justify-between border-b bg-white px-3 py-2.5 sm:px-5"
> >
<Breadcrumbs :items="breadbrumbs" /> <Breadcrumbs :items="breadbrumbs" />
<Button variant="solid" @click="showDialog = true"> <Button
v-if="user.data?.is_moderator || user.data?.is_instructor"
@click="showDialog = true"
variant="solid"
>
<template #prefix> <template #prefix>
<Plus class="w-4 h-4" /> <Plus class="h-4 w-4 stroke-1.5" />
</template> </template>
{{ __('New Program') }} {{ __('New') }}
</Button> </Button>
</header> </header>
<div class="pt-5 px-5"> <div v-if="programs.data?.length" class="pt-5 px-5">
<div v-if="programs.data?.length"> <div v-for="program in programs.data" class="mb-10">
<ListView <div class="flex items-center justify-between">
:columns="programColumns" <div class="text-xl font-semibold">
:rows="programs.data" {{ program.name }}
row-key="name" </div>
:options="{ showTooltip: false, selectable: false }" <div class="flex items-center space-x-2">
> <Badge v-if="program.members" variant="subtle" theme="green">
<ListHeader {{ program.members }} {{ __('Members') }}
class="mb-2 grid items-center space-x-4 rounded bg-gray-100 p-2" </Badge>
>
<ListHeaderItem :item="item" v-for="item in programColumns">
</ListHeaderItem>
</ListHeader>
<ListRows>
<router-link <router-link
v-for="row in programs.data" v-if="user.data?.is_moderator || user.data?.is_instructor"
:to="{ :to="{
name: 'ProgramForm', name: 'ProgramForm',
params: { params: { programName: program.name },
programName: row.name,
},
}" }"
> >
<ListRow :row="row" /> <Button>
<template #prefix>
<Edit class="h-4 w-4 stroke-1.5" />
</template>
{{ __('Edit') }}
</Button>
</router-link> </router-link>
</ListRows> </div>
</ListView> </div>
<div
v-if="program.courses?.length"
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5 mt-5"
>
<router-link
v-for="course in program.courses"
: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 },
}
"
>
<CourseCard :course="course" />
</router-link>
</div>
<div v-else class="text-sm italic text-gray-600 mt-4">
{{ __('No courses in this program') }}
</div>
</div> </div>
<div </div>
v-else <div
class="text-center p-5 text-gray-600 mt-52 w-3/4 md:w-1/2 mx-auto space-y-2" v-else
> class="text-center p-5 text-gray-600 mt-52 w-3/4 md:w-1/2 mx-auto space-y-2"
<Route class="size-10 mx-auto stroke-1 text-gray-500" /> >
<div class="text-xl font-medium"> <BookOpen class="size-10 mx-auto stroke-1 text-gray-500" />
{{ __('No programs found') }} <div class="text-xl font-medium">
</div> {{ __('No programs found') }}
<div class="leading-5"> </div>
{{ <div class="leading-5">
__( {{
'Program lets you create learning paths and assign them to your students. To create one, click on the "New Program" button above.' __(
) 'There are no programs available at the moment. Keep an eye out, fresh learning experiences are on the way soon!'
}} )
</div> }}
</div> </div>
</div> </div>
@@ -77,88 +116,46 @@
</template> </template>
<script setup> <script setup>
import { import {
Badge,
Breadcrumbs, Breadcrumbs,
Button, Button,
createListResource, call,
createResource,
Dialog, Dialog,
FormControl, FormControl,
ListView,
ListRows,
ListRow,
ListHeader,
ListHeaderItem,
} from 'frappe-ui' } from 'frappe-ui'
import { computed, inject, onMounted, ref } from 'vue' import { computed, inject, ref } from 'vue'
import { Plus, Route } from 'lucide-vue-next' import { BookOpen, Edit, Plus } from 'lucide-vue-next'
import CourseCard from '@/components/CourseCard.vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
const user = inject('$user') const user = inject('$user')
const router = useRouter()
const showDialog = ref(false) const showDialog = ref(false)
const router = useRouter()
const title = ref('') const title = ref('')
onMounted(() => { const programs = createResource({
if (!user.data?.is_moderator) { url: 'lms.lms.utils.get_programs',
router.push({ name: 'Courses' })
}
})
const programs = createListResource({
doctype: 'LMS Program',
fields: ['title', 'name', 'program_courses'],
auto: true, auto: true,
cache: 'programs', cache: 'programs',
transform(data) {
return data.map((program) => {
console.log(program)
program.program_courses = program.program_courses?.length
return program
})
},
}) })
const createProgram = async (close) => { console.log(programs)
programs.insert.submit(
{ const createProgram = (close) => {
call('frappe.client.insert', {
doc: {
doctype: 'LMS Program',
title: title.value, title: title.value,
}, },
{ }).then((res) => {
onSuccess(data) { router.push({ name: 'ProgramForm', params: { programName: res.name } })
showDialog.value = false })
router.push({ name: 'ProgramForm', params: { programName: data.name } })
},
}
)
} }
const breadbrumbs = computed(() => [ const breadbrumbs = computed(() => [
{ {
label: 'Programs', label: 'Programs',
route: {
name: 'Programs',
},
}, },
]) ])
const programColumns = computed(() => {
return [
{
label: __('Title'),
key: 'title',
width: 2,
},
{
label: __('Courses'),
key: 'program_courses',
width: 1,
align: 'center',
},
{
label: __('Members'),
key: 'program_members',
width: 1,
align: 'center',
},
]
})
</script> </script>

View File

@@ -182,17 +182,17 @@ const routes = [
component: () => import('@/pages/QuizSubmission.vue'), component: () => import('@/pages/QuizSubmission.vue'),
props: true, props: true,
}, },
{
path: '/programs',
name: 'Programs',
component: () => import('@/pages/Programs.vue'),
},
{ {
path: '/programs/:programName', path: '/programs/:programName',
name: 'ProgramForm', name: 'ProgramForm',
component: () => import('@/pages/ProgramForm.vue'), component: () => import('@/pages/ProgramForm.vue'),
props: true, props: true,
}, },
{
path: '/programs',
name: 'Programs',
component: () => import('@/pages/Programs.vue'),
},
] ]
let router = createRouter({ let router = createRouter({

View File

@@ -438,6 +438,8 @@ export function getSidebarLinks() {
'Lesson', 'Lesson',
'CourseForm', 'CourseForm',
'LessonForm', 'LessonForm',
'Programs',
'ProgramForm',
], ],
}, },
{ {

View File

@@ -34,7 +34,7 @@
], ],
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"links": [], "links": [],
"modified": "2024-11-18 14:08:26.958831", "modified": "2024-11-20 12:26:02.214628",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "LMS", "module": "LMS",
"name": "LMS Program", "name": "LMS Program",
@@ -52,6 +52,30 @@
"role": "System Manager", "role": "System Manager",
"share": 1, "share": 1,
"write": 1 "write": 1
},
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Moderator",
"share": 1,
"write": 1
},
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Course Creator",
"share": 1,
"write": 1
} }
], ],
"sort_field": "creation", "sort_field": "creation",

View File

@@ -1,9 +1,108 @@
# Copyright (c) 2024, Frappe and contributors # Copyright (c) 2024, Frappe and contributors
# For license information, please see license.txt # For license information, please see license.txt
# import frappe import frappe
from frappe import _
from frappe.model.document import Document from frappe.model.document import Document
class LMSProgram(Document): class LMSProgram(Document):
pass def validate(self):
self.validate_program_courses()
self.validate_program_members()
def on_update(self):
self.manage_acccess()
def manage_acccess(self):
old_doc = self.get_doc_before_save()
if not old_doc:
return
previous_courses = [row.course for row in old_doc.program_courses]
current_courses = [row.course for row in self.program_courses]
print("previous_courses", previous_courses)
print("current_courses", current_courses)
previous_members = [row.member for row in old_doc.program_members]
current_members = [row.member for row in self.program_members]
print("previous_members", previous_members)
print("current_members", current_members)
courses_added = [
course for course in current_courses if course not in previous_courses
]
courses_removed = [
course for course in previous_courses if course not in current_courses
]
members_added = [
member for member in current_members if member not in previous_members
]
members_removed = [
member for member in previous_members if member not in current_members
]
print(courses_removed)
print(members_removed)
if len(courses_added) > 0:
self.grant_program_access(current_members, courses_added)
if len(courses_removed) > 0:
print(courses_removed)
self.revoke_program_access(current_members, courses_removed)
if len(members_added) > 0:
self.grant_program_access(members_added, current_courses)
if len(members_removed) > 0:
print(members_removed)
self.revoke_program_access(members_removed, current_courses)
def grant_program_access(self, members, courses):
for course in courses:
for member in members:
enrollment = frappe.db.exists(
"LMS Enrollment", {"course": course, "member": member}
)
if not enrollment:
enrollment = frappe.new_doc("LMS Enrollment")
enrollment.course = course
enrollment.member = member
enrollment.insert()
def revoke_program_access(self, members, courses):
for course in courses:
print(course)
for member in members:
print(member)
enrollment = frappe.db.exists(
"LMS Enrollment", {"course": course, "member": member}
)
print(enrollment)
if enrollment:
frappe.delete_doc("LMS Enrollment", enrollment)
def validate_program_courses(self):
courses = [row.course for row in self.program_courses]
duplicates = {course for course in courses if courses.count(course) > 1}
if len(duplicates):
frappe.throw(
_("Course {0} has already been added to this batch.").format(
frappe.bold(next(iter(duplicates)))
)
)
def validate_program_members(self):
members = [row.member for row in self.program_members]
duplicates = {member for member in members if members.count(member) > 1}
if len(duplicates):
frappe.throw(
_("Member {0} has already been added to this batch.").format(
frappe.bold(next(iter(duplicates)))
)
)

View File

@@ -356,7 +356,7 @@
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"issingle": 1, "issingle": 1,
"links": [], "links": [],
"modified": "2024-11-18 12:52:41.236252", "modified": "2024-11-20 11:55:05.358421",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "LMS", "module": "LMS",
"name": "LMS Settings", "name": "LMS Settings",
@@ -371,6 +371,13 @@
"role": "System Manager", "role": "System Manager",
"share": 1, "share": 1,
"write": 1 "write": 1
},
{
"email": 1,
"print": 1,
"read": 1,
"role": "LMS Student",
"share": 1
} }
], ],
"sort_field": "modified", "sort_field": "modified",

View File

@@ -1751,3 +1751,29 @@ def enroll_in_batch(batch, payment_name=None):
) )
student.save(ignore_permissions=True) student.save(ignore_permissions=True)
@frappe.whitelist()
def get_programs():
if (
has_course_moderator_role()
or has_course_instructor_role()
or has_course_evaluator_role()
):
programs = frappe.get_all("LMS Program", fields=["name"])
else:
programs = frappe.get_all(
"LMS Program Member", {"member": frappe.session.user}, ["parent as name"]
)
for program in programs:
program_courses = frappe.get_all(
"LMS Program Course", {"parent": program.name}, ["course"]
)
program.courses = []
for course in program_courses:
program.courses.append(get_course_details(course.course))
program.members = frappe.db.count("LMS Program Member", {"parent": program.name})
return programs