Merge branch 'develop' into develop

This commit is contained in:
沨沄极客
2024-11-17 17:37:33 +08:00
committed by GitHub
20 changed files with 760 additions and 155 deletions

View File

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

View File

@@ -1,18 +1,18 @@
<template>
<div
class="flex h-full flex-col justify-between transition-all duration-300 ease-in-out bg-gray-50"
:class="isSidebarCollapsed ? 'w-14' : 'w-56'"
:class="sidebarStore.isSidebarCollapsed ? 'w-14' : 'w-56'"
>
<div
class="flex flex-col overflow-hidden"
:class="isSidebarCollapsed ? 'items-center' : ''"
:class="sidebarStore.isSidebarCollapsed ? 'items-center' : ''"
>
<UserDropdown :isCollapsed="isSidebarCollapsed" />
<UserDropdown :isCollapsed="sidebarStore.isSidebarCollapsed" />
<div class="flex flex-col" v-if="sidebarSettings.data">
<SidebarLink
v-for="link in sidebarLinks"
:link="link"
:isCollapsed="isSidebarCollapsed"
:isCollapsed="sidebarStore.isSidebarCollapsed"
class="mx-2 my-0.5"
/>
</div>
@@ -22,11 +22,11 @@
>
<div
class="flex items-center justify-between pr-2 cursor-pointer"
:class="isSidebarCollapsed ? 'pl-3' : 'pl-4'"
:class="sidebarStore.isSidebarCollapsed ? 'pl-3' : 'pl-4'"
@click="showWebPages = !showWebPages"
>
<div
v-if="!isSidebarCollapsed"
v-if="!sidebarStore.isSidebarCollapsed"
class="flex items-center text-sm text-gray-600 my-1"
>
<span class="grid h-5 w-6 flex-shrink-0 place-items-center">
@@ -53,7 +53,7 @@
<SidebarLink
v-for="link in sidebarSettings.data.web_pages"
:link="link"
:isCollapsed="isSidebarCollapsed"
:isCollapsed="sidebarStore.isSidebarCollapsed"
class="mx-2 my-0.5"
:showControls="isModerator ? true : false"
@openModal="openPageModal"
@@ -64,17 +64,19 @@
</div>
<SidebarLink
:link="{
label: isSidebarCollapsed ? 'Expand' : 'Collapse',
label: sidebarStore.isSidebarCollapsed ? 'Expand' : 'Collapse',
}"
:isCollapsed="isSidebarCollapsed"
@click="isSidebarCollapsed = !isSidebarCollapsed"
:isCollapsed="sidebarStore.isSidebarCollapsed"
@click="toggleSidebar()"
class="m-2"
>
<template #icon>
<span class="grid h-5 w-6 flex-shrink-0 place-items-center">
<CollapseSidebar
class="h-4.5 w-4.5 text-gray-700 duration-300 ease-in-out"
:class="{ '[transform:rotateY(180deg)]': isSidebarCollapsed }"
:class="{
'[transform:rotateY(180deg)]': sidebarStore.isSidebarCollapsed,
}"
/>
</span>
</template>
@@ -96,12 +98,14 @@ import { ref, onMounted, inject, watch } from 'vue'
import { getSidebarLinks } from '../utils'
import { usersStore } from '@/stores/user'
import { sessionStore } from '@/stores/session'
import { useSidebar } from '@/stores/sidebar'
import { ChevronRight, Plus } from 'lucide-vue-next'
import { createResource, Button } from 'frappe-ui'
import PageModal from '@/components/Modals/PageModal.vue'
const { user, sidebarSettings } = sessionStore()
const { userResource } = usersStore()
let sidebarStore = useSidebar()
const socket = inject('$socket')
const unreadCount = ref(0)
const sidebarLinks = ref(getSidebarLinks())
@@ -214,5 +218,8 @@ watch(userResource, () => {
}
})
let isSidebarCollapsed = ref(getSidebarFromStorage())
const toggleSidebar = () => {
console.log(sidebarStore.isSidebarCollapsed)
sidebarStore.isSidebarCollapsed = !sidebarStore.isSidebarCollapsed
}
</script>

View File

@@ -140,7 +140,7 @@ function enrollStudent() {
showToast(
__('Please Login'),
__('You need to login first to enroll for this course'),
'circle-warn'
'alert-circle'
)
setTimeout(() => {
window.location.href = `/login?redirect-to=${window.location.pathname}`

View File

@@ -1,5 +1,5 @@
<template>
<span v-if="instructors.length == 1">
<span v-if="instructors?.length == 1">
<router-link
:to="{
name: 'Profile',
@@ -9,7 +9,7 @@
{{ instructors[0].full_name }}
</router-link>
</span>
<span v-if="instructors.length == 2">
<span v-if="instructors?.length == 2">
<router-link
:to="{
name: 'Profile',
@@ -28,7 +28,7 @@
{{ instructors[1].first_name }}
</router-link>
</span>
<span v-if="instructors.length > 2">
<span v-if="instructors?.length > 2">
<router-link
:to="{
name: 'Profile',
@@ -37,7 +37,7 @@
>
{{ instructors[0].first_name }}
</router-link>
and {{ instructors.length - 1 }} others
and {{ instructors?.length - 1 }} others
</span>
</template>
<script setup>

View File

@@ -16,7 +16,7 @@
</div>
<div
:class="{
'shadow rounded-md pt-2 px-2': showOutline && outline.data?.length,
'shadow rounded-md py-2 px-2': showOutline && outline.data?.length,
}"
>
<Disclosure
@@ -25,21 +25,42 @@
:key="chapter.name"
:defaultOpen="openChapterDetail(chapter.idx)"
>
<DisclosureButton ref="" class="flex w-full p-2">
<DisclosureButton ref="" class="flex items-center w-full p-2 group">
<ChevronRight
:class="{
'rotate-90 transform duration-200': open,
'duration-200': !open,
hidden: chapter.is_scorm_package,
open: index == 1,
}"
class="h-4 w-4 text-gray-900 stroke-1 mr-2"
class="h-4 w-4 text-gray-900 stroke-1"
/>
<div class="text-base text-left font-medium leading-5">
<div
class="text-base text-left font-medium leading-5 ml-2"
@click="redirectToChapter(chapter)"
>
{{ chapter.title }}
</div>
<div class="flex ml-auto space-x-4">
<Tooltip :text="__('Edit Chapter')" placement="bottom">
<FilePenLine
v-if="allowEdit"
@click.prevent="openChapterModal(chapter)"
class="h-4 w-4 text-gray-900 invisible group-hover:visible"
/>
</Tooltip>
<Tooltip :text="__('Delete Chapter')" placement="bottom">
<Trash2
v-if="allowEdit"
@click.prevent="trashChapter(chapter.name)"
class="h-4 w-4 text-red-500 invisible group-hover:visible"
/>
</Tooltip>
</div>
</DisclosureButton>
<DisclosurePanel>
<DisclosurePanel v-if="!chapter.is_scorm_package">
<Draggable
v-if="!chapter.is_scorm_package"
:list="chapter.lessons"
:disabled="!allowEdit"
item-key="name"
@@ -89,6 +110,7 @@
</Draggable>
<div v-if="allowEdit" class="flex mt-2 mb-4 pl-8">
<router-link
v-if="!chapter.is_scorm_package"
:to="{
name: 'LessonForm',
params: {
@@ -102,9 +124,6 @@
{{ __('Add Lesson') }}
</Button>
</router-link>
<Button class="ml-2" @click="openChapterModal(chapter)">
{{ __('Edit Chapter') }}
</Button>
</div>
</DisclosurePanel>
</Disclosure>
@@ -118,24 +137,26 @@
/>
</template>
<script setup>
import { Button, createResource } from 'frappe-ui'
import { ref, getCurrentInstance } from 'vue'
import { Button, createResource, Tooltip } from 'frappe-ui'
import { getCurrentInstance, inject, ref } from 'vue'
import Draggable from 'vuedraggable'
import { Disclosure, DisclosureButton, DisclosurePanel } from '@headlessui/vue'
import {
ChevronRight,
MonitorPlay,
HelpCircle,
FileText,
Check,
ChevronRight,
FileText,
FilePenLine,
HelpCircle,
MonitorPlay,
Trash2,
} from 'lucide-vue-next'
import { useRoute } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import ChapterModal from '@/components/Modals/ChapterModal.vue'
import { showToast } from '@/utils'
const route = useRoute()
const expandAll = ref(true)
const router = useRouter()
const user = inject('$user')
const showChapterModal = ref(false)
const currentChapter = ref(null)
const app = getCurrentInstance()
@@ -205,8 +226,10 @@ const updateLessonIndex = createResource({
const trashLesson = (lessonName, chapterName) => {
$dialog({
title: __('Delete Lesson'),
message: __('Are you sure you want to delete this lesson?'),
title: __('Delete this lesson?'),
message: __(
'Deleting this lesson will permanently remove it from the course. This action cannot be undone. Are you sure you want to continue?'
),
actions: [
{
label: __('Delete'),
@@ -245,6 +268,61 @@ const updateOutline = (e) => {
idx: e.newIndex,
})
}
const deleteChapter = createResource({
url: 'lms.lms.api.delete_chapter',
makeParams(values) {
return {
chapter: values.chapter,
}
},
onSuccess() {
outline.reload()
showToast('Success', 'Chapter deleted successfully', 'check')
},
})
const trashChapter = (chapterName) => {
$dialog({
title: __('Delete this chapter?'),
message: __(
'Deleting this chapter will also delete all its lessons and permanently remove it from the course. This action cannot be undone. Are you sure you want to continue?'
),
actions: [
{
label: __('Delete'),
theme: 'red',
variant: 'solid',
onClick(close) {
deleteChapter.submit({ chapter: chapterName })
close()
},
},
],
})
}
const redirectToChapter = (chapter) => {
event.preventDefault()
if (props.allowEdit) return
if (!chapter.is_scorm_package) return
if (!user.data) {
showToast(
__('You are not enrolled'),
__('Please enroll for this course to view this lesson'),
'alert-circle'
)
return
}
router.push({
name: 'SCORMChapter',
params: {
courseName: props.courseName,
chapterName: chapter.name,
},
})
}
</script>
<style>
.outline-lesson:has(.router-link-active) {

View File

@@ -9,7 +9,7 @@
allowfullscreen
></iframe>
</div>
<div v-for="block in content.split('\n\n')">
<div v-for="block in content?.split('\n\n')">
<div v-if="block.includes('{{ YouTubeVideo')">
<iframe
class="youtube-video"

View File

@@ -15,21 +15,71 @@
}"
>
<template #body-content>
<FormControl
ref="chapterInput"
label="Title"
v-model="chapter.title"
class="mb-4"
:required="true"
/>
<div class="space-y-4 text-base">
<FormControl
ref="chapterInput"
label="Title"
v-model="chapter.title"
:required="true"
/>
<FormControl
:label="__('Is SCORM Package')"
v-model="chapter.is_scorm_package"
type="checkbox"
/>
<div v-if="chapter.is_scorm_package">
<FileUploader
v-if="!chapter.scorm_package"
:fileTypes="['.zip']"
:validateFile="validateFile"
@success="(file) => (chapter.scorm_package = file)"
>
<template v-slot="{ file, progress, uploading, openFileSelector }">
<div class="mb-4">
<Button @click="openFileSelector" :loading="uploading">
{{
uploading ? `Uploading ${progress}%` : 'Upload an zip file'
}}
</Button>
</div>
</template>
</FileUploader>
<div v-else class="">
<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>
<div class="flex flex-col">
<span>
{{ chapter.scorm_package.file_name }}
</span>
<span class="text-sm text-gray-500 mt-1">
{{ getFileSize(chapter.scorm_package.file_size) }}
</span>
</div>
<X
@click="() => (chapter.scorm_package = null)"
class="bg-gray-200 rounded-md cursor-pointer stroke-1.5 w-5 h-5 p-1 ml-4"
/>
</div>
</div>
</div>
</div>
</template>
</Dialog>
</template>
<script setup>
import { Dialog, FormControl, createResource } from 'frappe-ui'
import {
Button,
createResource,
Dialog,
FileUploader,
FormControl,
} from 'frappe-ui'
import { defineModel, reactive, watch, ref } from 'vue'
import { createToast } from '@/utils/'
import { showToast, getFileSize } from '@/utils/'
import { capture } from '@/telemetry'
import { FileText, X } from 'lucide-vue-next'
const show = defineModel()
const outline = defineModel('outline')
@@ -47,30 +97,19 @@ const props = defineProps({
const chapter = reactive({
title: '',
is_scorm_package: 0,
scorm_package: null,
})
const chapterResource = createResource({
url: 'frappe.client.insert',
url: 'lms.lms.api.upsert_chapter',
makeParams(values) {
return {
doc: {
doctype: 'Course Chapter',
title: chapter.title,
description: chapter.description,
course: props.course,
},
}
},
})
const chapterEditResource = createResource({
url: 'frappe.client.set_value',
makeParams(values) {
return {
doctype: 'Course Chapter',
title: chapter.title,
course: props.course,
is_scorm_package: chapter.is_scorm_package,
scorm_package: chapter.scorm_package,
name: props.chapterDetail?.name,
fieldname: 'title',
value: chapter.title,
}
},
})
@@ -90,14 +129,12 @@ const chapterReference = createResource({
},
})
const addChapter = (close) => {
const addChapter = async (close) => {
chapterResource.submit(
{},
{
validate() {
if (!chapter.title) {
return 'Title is required'
}
return validateChapter()
},
onSuccess: (data) => {
capture('chapter_created')
@@ -105,30 +142,45 @@ const addChapter = (close) => {
{ name: data.name },
{
onSuccess(data) {
chapter.title = ''
cleanChapter()
outline.value.reload()
createToast({
text: 'Chapter added successfully',
icon: 'check',
iconClasses: 'bg-green-600 text-white rounded-md p-px',
})
showToast(
__('Success'),
__('Chapter added successfully'),
'check'
)
},
onError(err) {
showError(err)
showToast(__('Error'), err.messages?.[0] || err, 'x')
},
}
)
close()
},
onError(err) {
showError(err)
showToast(__('Error'), err.messages?.[0] || err, 'x')
},
}
)
}
const validateChapter = () => {
if (!chapter.title) {
return __('Title is required')
}
if (chapter.is_scorm_package && !chapter.scorm_package) {
return __('Please upload a SCORM package')
}
}
const cleanChapter = () => {
chapter.title = ''
chapter.is_scorm_package = 0
chapter.scorm_package = null
}
const editChapter = (close) => {
chapterEditResource.submit(
chapterResource.submit(
{},
{
validate() {
@@ -138,43 +190,37 @@ const editChapter = (close) => {
},
onSuccess() {
outline.value.reload()
createToast({
text: 'Chapter updated successfully',
icon: 'check',
iconClasses: 'bg-green-600 text-white rounded-md p-px',
})
showToast(__('Success'), __('Chapter updated successfully'), 'check')
close()
},
onError(err) {
showError(err)
showToast(__('Error'), err.messages?.[0] || err, 'x')
},
}
)
}
const showError = (err) => {
createToast({
title: 'Error',
text: err.messages?.[0] || err,
icon: 'x',
iconClasses: 'bg-red-600 text-white rounded-md p-px',
position: 'top-center',
timeout: 10,
})
}
watch(
() => props.chapterDetail,
(newChapter) => {
chapter.title = newChapter?.title
chapter.is_scorm_package = newChapter?.is_scorm_package
chapter.scorm_package = newChapter?.scorm_package
}
)
watch(show, () => {
/* watch(show, () => {
if (show.value) {
setTimeout(() => {
chapterInput.value.$el.querySelector('input').focus()
}, 100)
}
})
}) */
const validateFile = (file) => {
let extension = file.name.split('.').pop().toLowerCase()
if (extension !== 'zip') {
return __('Only zip files are allowed')
}
}
</script>

View File

@@ -70,7 +70,11 @@
class="ProseMirror prose prose-table:table-fixed prose-td:p-2 prose-th:p-2 prose-td:border prose-th:border prose-td:border-gray-300 prose-th:border-gray-300 prose-td:relative prose-th:relative prose-th:bg-gray-100 prose-sm max-w-none !whitespace-normal"
></div>
<div class="mt-10">
<CourseOutline :courseName="course.data.name" :showOutline="true" />
<CourseOutline
:title="__('Course Outline')"
:courseName="course.data.name"
:showOutline="true"
/>
</div>
<CourseReviews
:courseName="course.data.name"

View File

@@ -7,7 +7,22 @@
class="h-7"
:items="[{ label: __('Jobs'), route: { name: 'Jobs' } }]"
/>
<div class="flex">
<div class="flex space-x-2">
<div class="w-40 md:w-44">
<FormControl
v-model="jobType"
type="select"
:options="jobTypes"
:placeholder="__('Type')"
/>
</div>
<div class="w-28 md:w-36">
<FormControl type="text" placeholder="Search" v-model="searchQuery">
<template #prefix>
<Search class="w-4 h-4 stroke-1.5 text-gray-600" name="search" />
</template>
</FormControl>
</div>
<router-link
v-if="user.data?.name"
:to="{
@@ -26,9 +41,9 @@
</router-link>
</div>
</header>
<div v-if="jobs.data?.length">
<div v-if="jobsList?.length">
<div class="divide-y lg:w-3/4 mx-auto p-5">
<div v-for="job in jobs.data">
<div v-for="job in jobsList">
<router-link
:to="{
name: 'JobDetail',
@@ -47,13 +62,22 @@
</div>
</template>
<script setup>
import { Button, Breadcrumbs, createResource } from 'frappe-ui'
import { Plus } from 'lucide-vue-next'
import { inject, computed } from 'vue'
import { Button, Breadcrumbs, createResource, FormControl } from 'frappe-ui'
import { Plus, Search } from 'lucide-vue-next'
import { inject, computed, ref, onMounted } from 'vue'
import JobCard from '@/components/JobCard.vue'
import { updateDocumentTitle } from '@/utils'
const user = inject('$user')
const jobType = ref(null)
const searchQuery = ref('')
onMounted(() => {
let queries = new URLSearchParams(location.search)
if (queries.has('type')) {
jobType.value = queries.get('type')
}
})
const jobs = createResource({
url: 'lms.lms.api.get_job_opportunities',
@@ -68,5 +92,32 @@ const pageMeta = computed(() => {
}
})
const jobsList = computed(() => {
let jobData = jobs.data
if (jobType.value && jobType.value != '') {
jobData = jobData.filter((job) => job.type == jobType.value)
}
if (searchQuery.value) {
let query = searchQuery.value.toLowerCase()
jobData = jobData.filter(
(job) =>
job.job_title.toLowerCase().includes(query) ||
job.company_name.toLowerCase().includes(query) ||
job.location.toLowerCase().includes(query)
)
}
return jobData
})
const jobTypes = computed(() => {
return [
'',
{ label: __('Full Time'), value: 'Full Time' },
{ label: __('Part Time'), value: 'Part Time' },
{ label: __('Contract'), value: 'Contract' },
{ label: __('Freelance'), value: 'Freelance' },
]
})
updateDocumentTitle(pageMeta)
</script>

View File

@@ -103,7 +103,7 @@
<span
class="h-6 mr-1"
:class="{
'avatar-group overlap': lesson.data.instructors.length > 1,
'avatar-group overlap': lesson.data.instructors?.length > 1,
}"
>
<UserAvatar
@@ -111,7 +111,10 @@
:user="instructor"
/>
</span>
<CourseInstructors :instructors="lesson.data.instructors" />
<CourseInstructors
v-if="lesson.data?.instructors"
:instructors="lesson.data.instructors"
/>
</div>
<div
v-if="
@@ -146,6 +149,7 @@
class="ProseMirror prose prose-table:table-fixed prose-td:p-2 prose-th:p-2 prose-td:border prose-th:border prose-td:border-gray-300 prose-th:border-gray-300 prose-td:relative prose-th:relative prose-th:bg-gray-100 prose-sm max-w-none !whitespace-normal mt-5"
>
<LessonContent
v-if="lesson.data?.body"
:content="lesson.data.body"
:youtube="lesson.data.youtube"
:quizId="lesson.data.quiz_id"
@@ -240,7 +244,10 @@ const lesson = createResource({
auto: true,
onSuccess(data) {
if (Object.keys(data).length === 0) {
router.push({ name: 'Courses' })
router.push({
name: 'CourseDetail',
params: { courseName: props.courseName },
})
return
}
lessonProgress.value = data.membership?.progress
@@ -369,13 +376,13 @@ const checkIfDiscussionsAllowed = () => {
const allowEdit = () => {
if (user.data?.is_moderator) return true
if (lesson.data?.instructors.includes(user.data?.name)) return true
if (lesson.data?.instructors?.includes(user.data?.name)) return true
return false
}
const allowInstructorContent = () => {
if (user.data?.is_moderator) return true
if (lesson.data?.instructors.includes(user.data?.name)) return true
if (lesson.data?.instructors?.includes(user.data?.name)) return true
return false
}

View File

@@ -0,0 +1,205 @@
<template>
<header
class="sticky top-0 z-10 flex items-center justify-between border-b bg-white px-3 py-2.5 sm:px-5"
>
<Breadcrumbs class="h-7" :items="breadcrumbs" />
</header>
<div
v-if="
readyToRender &&
(enrollment.data?.length ||
user.data?.is_moderator ||
user.data?.is_instructor)
"
>
<iframe :src="chapter.doc.launch_file" class="w-full h-screen" />
</div>
<div v-else-if="!enrollment.data?.length">
<div class="text-center pt-10 px-5 md:px-0 pb-10">
<div class="text-center">
<div class="mb-4">
{{
__(
'You are not enrolled in this course. Please enroll to access this lesson.'
)
}}
</div>
<Button variant="solid" @click="enrollStudent()">
{{ __('Start Learning') }}
</Button>
</div>
</div>
</div>
</template>
<script setup>
import {
Breadcrumbs,
Button,
call,
createDocumentResource,
createListResource,
createResource,
} from 'frappe-ui'
import { computed, inject, onBeforeMount, ref } from 'vue'
import { useSidebar } from '@/stores/sidebar'
import { updateDocumentTitle } from '@/utils'
const sidebarStore = useSidebar()
const user = inject('$user')
const readyToRender = ref(false)
const props = defineProps({
courseName: {
type: String,
required: true,
},
chapterName: {
type: String,
required: true,
},
})
onBeforeMount(() => {
sidebarStore.isSidebarCollapsed = true
window.API_1484_11 = {
Initialize: () => 'true',
Terminate: () => 'true',
GetValue: (key) => {
console.log(`GetValue called for key: ${key}`)
return getDataFromLMS(key)
},
SetValue: (key, value) => {
console.log(`SetValue called for key: ${key} to value: ${value}`)
saveDataToLMS(key, value)
return 'true'
},
Commit: () => 'true',
GetLastError: () => '0',
GetErrorString: () => '',
GetDiagnostic: () => '',
}
window.API = {
LMSInitialize: () => 'true',
LMSFinish: () => 'true',
LMSGetValue: (key) => {
console.log(`GET: ${key}`)
return getDataFromLMS(key)
},
LMSSetValue: (key, value) => {
console.log(`SET: ${key} to value: ${value}`)
saveDataToLMS(key, value)
return 'true'
},
LMSCommit: () => 'true',
LMSGetLastError: () => '0',
LMSGetErrorString: () => '',
LMSGetDiagnostic: () => '',
}
console.log(window.API_1484_11)
})
const getDataFromLMS = (key) => {
if (key == 'cmi.core.lesson_status') {
if (progress.data?.status == 'Complete') {
return 'passed'
}
return 'incomplete'
}
return ''
}
const saveDataToLMS = (key, value) => {
if (key == 'cmi.core.lesson_status' && value == 'passed') {
saveProgress()
}
}
const enrollment = createListResource({
doctype: 'LMS Enrollment',
fields: ['member', 'course'],
filters: {
course: props.courseName,
member: user.data?.name,
},
auto: true,
cache: ['enrollments', props.courseName, user.data?.name],
})
const chapter = createDocumentResource({
doctype: 'Course Chapter',
name: props.chapterName,
auto: true,
cache: ['chapter', props.chapterName],
onSuccess(data) {
progress.submit()
},
})
const saveProgress = () => {
call('lms.lms.doctype.course_lesson.course_lesson.save_progress', {
lesson: chapter.doc.lessons[0].lesson,
course: props.courseName,
})
}
const progress = createResource({
url: 'frappe.client.get_value',
makeParams(values) {
return {
doctype: 'LMS Course Progress',
fieldname: 'status',
filters: {
member: user.data?.name,
lesson: chapter.doc.lessons[0].lesson,
chapter: chapter.doc.name,
course: chapter.doc?.course,
},
}
},
onSuccess(data) {
readyToRender.value = true
},
})
const enrollStudent = () => {
enrollment.insert.submit(
{
course: props.courseName,
member: user.data?.name,
},
{
onSuccess(data) {
window.location.reload()
},
}
)
}
const breadcrumbs = computed(() => {
return [
{
label: 'Courses',
route: { name: 'Courses' },
},
{
label: chapter.doc?.course_title,
route: { name: 'CourseDetail', params: { courseName: props.courseName } },
},
{
label: chapter.doc?.title,
},
]
})
const pageMeta = computed(() => {
return {
title: chapter?.doc?.title,
description: __('This is a chapter in the course {0}').format(
chapter?.doc?.course_title
),
}
})
updateDocumentTitle(pageMeta)
</script>

View File

@@ -27,6 +27,12 @@ const routes = [
component: () => import('@/pages/Lesson.vue'),
props: true,
},
{
path: '/courses/:courseName/learn/:chapterName',
name: 'SCORMChapter',
component: () => import('@/pages/SCORMChapter.vue'),
props: true,
},
{
path: '/batches',
name: 'Batches',

View File

@@ -0,0 +1,10 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
export const useSidebar = defineStore('sidebar', () => {
const isSidebarCollapsed = ref(false)
return {
isSidebarCollapsed,
}
})

View File

@@ -93,7 +93,7 @@ export function showToast(title, text, icon, iconClasses = null) {
if (!iconClasses) {
if (icon == 'check') {
iconClasses = 'bg-green-600 text-white rounded-md p-px'
} else if (icon == 'circle-warn') {
} else if (icon == 'alert-circle') {
iconClasses = 'bg-yellow-600 text-white rounded-md p-px'
} else {
iconClasses = 'bg-red-600 text-white rounded-md p-px'

View File

@@ -3,6 +3,10 @@
import json
import frappe
import zipfile
import os
import shutil
import xml.etree.ElementTree as ET
from frappe.translate import get_all_translations
from frappe import _
from frappe.query_builder import DocType
@@ -10,6 +14,7 @@ from frappe.query_builder.functions import Count
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
from xml.dom.minidom import parseString
@frappe.whitelist()
@@ -876,3 +881,124 @@ def give_dicussions_permission():
"delete": 1,
}
).save(ignore_permissions=True)
@frappe.whitelist()
def upsert_chapter(title, course, is_scorm_package, scorm_package, name=None):
values = frappe._dict(
{"title": title, "course": course, "is_scorm_package": is_scorm_package}
)
if is_scorm_package:
scorm_package = frappe._dict(scorm_package)
extract_path = extract_package(course, title, scorm_package)
values.update(
{
"scorm_package": scorm_package.name,
"scorm_package_path": extract_path.split("public")[1],
"manifest_file": get_manifest_file(extract_path).split("public")[1],
"launch_file": get_launch_file(extract_path).split("public")[1],
}
)
if name:
chapter = frappe.get_doc("Course Chapter", name)
else:
chapter = frappe.new_doc("Course Chapter")
chapter.update(values)
chapter.save()
if is_scorm_package and not len(chapter.lessons):
add_lesson(title, chapter.name, course)
return chapter
def extract_package(course, title, scorm_package):
package = frappe.get_doc("File", scorm_package.name)
zip_path = package.get_full_path()
extract_path = frappe.get_site_path("public", "files", "scorm", course, title)
zipfile.ZipFile(zip_path).extractall(extract_path)
return extract_path
def get_manifest_file(extract_path):
manifest_file = None
for root, dirs, files in os.walk(extract_path):
for file in files:
if file == "imsmanifest.xml":
manifest_file = os.path.join(root, file)
break
if manifest_file:
break
return manifest_file
def get_launch_file(extract_path):
launch_file = None
manifest_file = get_manifest_file(extract_path)
if manifest_file:
with open(manifest_file) as file:
data = file.read()
dom = parseString(data)
resource = dom.getElementsByTagName("resource")
for res in resource:
if (
res.getAttribute("adlcp:scormtype") == "sco"
or res.getAttribute("adlcp:scormType") == "sco"
):
launch_file = res.getAttribute("href")
break
if launch_file:
launch_file = os.path.join(os.path.dirname(manifest_file), launch_file)
return launch_file
def add_lesson(title, chapter, course):
lesson = frappe.new_doc("Course Lesson")
lesson.update(
{
"title": title,
"chapter": chapter,
"course": course,
}
)
lesson.insert()
lesson_reference = frappe.new_doc("Lesson Reference")
lesson_reference.update(
{
"lesson": lesson.name,
"parent": chapter,
"parenttype": "Course Chapter",
"parentfield": "lessons",
}
)
lesson_reference.insert()
@frappe.whitelist()
def delete_chapter(chapter):
chapterInfo = frappe.db.get_value(
"Course Chapter", chapter, ["is_scorm_package", "scorm_package_path"], as_dict=True
)
if chapterInfo.is_scorm_package:
delete_scorm_package(chapterInfo.scorm_package_path)
frappe.db.delete("Chapter Reference", {"chapter": chapter})
frappe.db.delete("Lesson Reference", {"parent": chapter})
frappe.db.delete("Course Lesson", {"chapter": chapter})
frappe.db.delete("Course Chapter", chapter)
def delete_scorm_package(scorm_package_path):
scorm_package_path = frappe.get_site_path("public", scorm_package_path)
if os.path.exists(scorm_package_path):
shutil.rmtree(scorm_package_path)

View File

@@ -8,9 +8,17 @@
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"course",
"column_break_3",
"title",
"column_break_3",
"course",
"course_title",
"scorm_section",
"is_scorm_package",
"scorm_package",
"scorm_package_path",
"column_break_dlnw",
"manifest_file",
"launch_file",
"section_break_5",
"lessons"
],
@@ -43,6 +51,56 @@
"fieldtype": "Table",
"label": "Lessons",
"options": "Lesson Reference"
},
{
"default": "0",
"fieldname": "is_scorm_package",
"fieldtype": "Check",
"label": "Is SCORM Package"
},
{
"depends_on": "is_scorm_package",
"fieldname": "manifest_file",
"fieldtype": "Code",
"label": "Manifest File",
"read_only": 1
},
{
"depends_on": "is_scorm_package",
"fieldname": "launch_file",
"fieldtype": "Code",
"label": "Launch File",
"read_only": 1
},
{
"fieldname": "scorm_section",
"fieldtype": "Section Break",
"label": "SCORM"
},
{
"fieldname": "scorm_package",
"fieldtype": "Link",
"label": "SCORM Package",
"options": "File",
"read_only": 1
},
{
"fieldname": "column_break_dlnw",
"fieldtype": "Column Break"
},
{
"depends_on": "is_scorm_package",
"fieldname": "scorm_package_path",
"fieldtype": "Code",
"label": "SCORM Package Path",
"read_only": 1
},
{
"fetch_from": "course.title",
"fieldname": "course_title",
"fieldtype": "Data",
"label": "Course Title",
"read_only": 1
}
],
"index_web_pages_for_search": 1,
@@ -53,7 +111,7 @@
"link_fieldname": "chapter"
}
],
"modified": "2024-10-29 16:54:20.904683",
"modified": "2024-11-15 12:03:31.370943",
"modified_by": "Administrator",
"module": "LMS",
"name": "Course Chapter",
@@ -73,17 +131,14 @@
"write": 1
},
{
"create": 1,
"email": 1,
"export": 1,
"if_owner": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "LMS Student",
"select": 1,
"share": 1,
"write": 1
"share": 1
}
],
"search_fields": "title",

View File

@@ -8,12 +8,18 @@
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"chapter",
"course",
"column_break_4",
"title",
"include_in_preview",
"index_label",
"column_break_4",
"chapter",
"is_scorm_package",
"course",
"section_break_11",
"content",
"body",
"column_break_cjmf",
"instructor_content",
"instructor_notes",
"section_break_6",
"youtube",
"column_break_9",
@@ -22,13 +28,7 @@
"question",
"column_break_15",
"file_type",
"section_break_11",
"content",
"body",
"column_break_cjmf",
"instructor_content",
"instructor_notes",
"help_section",
"column_break_syza",
"help"
],
"fields": [
@@ -59,12 +59,6 @@
"label": "Title",
"reqd": 1
},
{
"fieldname": "index_label",
"fieldtype": "Data",
"label": "Index Label",
"read_only": 1
},
{
"fieldname": "section_break_6",
"fieldtype": "Section Break",
@@ -74,14 +68,7 @@
"fieldname": "body",
"fieldtype": "Markdown Editor",
"ignore_xss_filter": 1,
"label": "Body",
"reqd": 1
},
{
"fieldname": "help_section",
"fieldtype": "Section Break",
"hidden": 1,
"label": "Help"
"label": "Body"
},
{
"fieldname": "help",
@@ -158,11 +145,23 @@
"fieldname": "instructor_content",
"fieldtype": "Text",
"label": "Instructor Content"
},
{
"fieldname": "column_break_syza",
"fieldtype": "Column Break"
},
{
"default": "0",
"fetch_from": "chapter.is_scorm_package",
"fieldname": "is_scorm_package",
"fieldtype": "Check",
"label": "Is SCORM Package",
"read_only": 1
}
],
"index_web_pages_for_search": 1,
"links": [],
"modified": "2024-10-08 11:04:54.748773",
"modified": "2024-11-14 13:46:56.838659",
"modified_by": "Administrator",
"module": "LMS",
"name": "Course Lesson",

View File

@@ -52,7 +52,6 @@ class CourseLesson(Document):
ex.lesson = None
ex.course = None
ex.index_ = 0
ex.index_label = ""
ex.save(ignore_permissions=True)
def check_and_create_folder(self):

View File

@@ -1128,11 +1128,20 @@ def get_course_outline(course, progress=False):
chapter_details = frappe.db.get_value(
"Course Chapter",
chapter.chapter,
["name", "title"],
["name", "title", "is_scorm_package", "launch_file", "scorm_package"],
as_dict=True,
)
chapter_details["idx"] = chapter.idx
chapter_details.lessons = get_lessons(course, chapter_details, progress=progress)
if chapter_details.is_scorm_package:
chapter_details.scorm_package = frappe.db.get_value(
"File",
chapter_details.scorm_package,
["file_name", "file_size", "file_url"],
as_dict=1,
)
outline.append(chapter_details)
return outline
@@ -1146,9 +1155,12 @@ def get_lesson(course, chapter, lesson):
"Lesson Reference", {"parent": chapter_name, "idx": lesson}, "lesson"
)
lesson_details = frappe.db.get_value(
"Course Lesson", lesson_name, ["include_in_preview", "title"], as_dict=1
"Course Lesson",
lesson_name,
["include_in_preview", "title", "is_scorm_package"],
as_dict=1,
)
if not lesson_details:
if not lesson_details or lesson_details.is_scorm_package:
return {}
membership = get_membership(course)

View File

@@ -3,7 +3,7 @@ msgstr ""
"Project-Id-Version: frappe\n"
"Report-Msgid-Bugs-To: jannat@frappe.io\n"
"POT-Creation-Date: 2024-11-08 16:04+0000\n"
"PO-Revision-Date: 2024-11-12 15:48\n"
"PO-Revision-Date: 2024-11-13 15:54\n"
"Last-Translator: jannat@frappe.io\n"
"Language-Team: Turkish\n"
"MIME-Version: 1.0\n"
@@ -957,7 +957,7 @@ msgstr ""
#: frontend/src/pages/Statistics.vue:58
msgid "Completions"
msgstr "Tamamlamalar"
msgstr "Tamamlama"
#. Label of the condition (Code) field in DocType 'LMS Badge'
#: lms/lms/doctype/lms_badge/lms_badge.json
@@ -1603,7 +1603,7 @@ msgstr "Hemen Kaydol"
#: frontend/src/components/CourseCardOverlay.vue:103
#: frontend/src/pages/CourseDetail.vue:31
msgid "Enrolled Students"
msgstr "Kayıtlı Öğrenciler"
msgstr "Kayıtlı Öğrenci"
#: lms/public/js/common_functions.js:96
msgid "Enrolled successfully"
@@ -2632,12 +2632,12 @@ msgstr "Ders Başlığı"
#: lms/lms/doctype/course_chapter/course_chapter.json
#: lms/lms/doctype/lms_course/lms_course.json
msgid "Lessons"
msgstr "Dersler"
msgstr "Ders"
#: lms/lms/web_template/lms_statistics/lms_statistics.html:14
#: lms/templates/statistics.html:36
msgid "Lessons Completed"
msgstr "Dersler Tamamlandı"
msgstr "Ders Tamamlandı"
#: lms/templates/onboarding_header.html:11
msgid "Lets start setting up your content on the LMS so that you can reclaim time and focus on growth."
@@ -4238,7 +4238,7 @@ msgstr "Kayıt Ayarları"
#. Label of a chart in the LMS Workspace
#: frontend/src/pages/Statistics.vue:32 lms/lms/workspace/lms/lms.json
msgid "Signups"
msgstr "Kaydolanlar"
msgstr "Kayıtlar"
#. Label of the skill (Table MultiSelect) field in DocType 'User'
#. Label of the skill (Data) field in DocType 'User Skill'
@@ -4448,7 +4448,7 @@ msgstr "Öğrenci Adı"
#: frontend/src/components/CourseReviews.vue:11
msgid "Student Reviews"
msgstr ""
msgstr "Öğrenci İncelemeleri"
#: lms/lms/doctype/lms_batch/lms_batch.py:47
msgid "Student {0} has already been added to this batch."
@@ -4711,7 +4711,7 @@ msgstr ""
#: frontend/src/components/CourseCardOverlay.vue:91
msgid "This course has:"
msgstr ""
msgstr "Bu kursta:"
#: lms/lms/utils.py:1570
msgid "This course is free."
@@ -4854,7 +4854,7 @@ msgstr ""
#: frontend/src/components/LessonHelp.vue:34
msgid "To upload Image, Video, Audio or PDF from your system, click on the add icon and select upload from the menu. Then choose the file you want to add to the lesson and it gets added to your lesson."
msgstr ""
msgstr "Sisteminizden Resim, Video, Ses veya PDF yüklemek için ekle simgesine tıklayın ve menüden yüklemeyi seçin. Ardından derse eklemek istediğiniz dosyayı seçin ve dersinize eklensin."
#: lms/overrides/user.py:206
msgid "Too many users signed up recently, so the registration is disabled. Please try back in an hour"