feat: SCORM
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -89,6 +89,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: {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -15,20 +15,66 @@
|
||||
}"
|
||||
>
|
||||
<template #body-content>
|
||||
<FormControl
|
||||
ref="chapterInput"
|
||||
label="Title"
|
||||
v-model="chapter.title"
|
||||
class="mb-4"
|
||||
/>
|
||||
<div class="space-y-4">
|
||||
<FormControl ref="chapterInput" label="Title" v-model="chapter.title" />
|
||||
<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')
|
||||
@@ -46,18 +92,18 @@ 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.add_chapter',
|
||||
makeParams(values) {
|
||||
return {
|
||||
doc: {
|
||||
doctype: 'Course Chapter',
|
||||
title: chapter.title,
|
||||
description: chapter.description,
|
||||
course: props.course,
|
||||
},
|
||||
title: chapter.title,
|
||||
course: props.course,
|
||||
is_scorm_package: chapter.is_scorm_package,
|
||||
scorm_package: chapter.scorm_package,
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -89,13 +135,16 @@ const chapterReference = createResource({
|
||||
},
|
||||
})
|
||||
|
||||
const addChapter = (close) => {
|
||||
const addChapter = async (close) => {
|
||||
chapterResource.submit(
|
||||
{},
|
||||
{
|
||||
validate() {
|
||||
if (!chapter.title) {
|
||||
return 'Title is required'
|
||||
return __('Title is required')
|
||||
}
|
||||
if (chapter.is_scorm_package && !chapter.scorm_package) {
|
||||
return __('Please upload a SCORM package')
|
||||
}
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
@@ -104,28 +153,34 @@ 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 cleanChapter = () => {
|
||||
chapter.title = ''
|
||||
chapter.is_scorm_package = 0
|
||||
chapter.scorm_package = null
|
||||
}
|
||||
|
||||
const editChapter = (close) => {
|
||||
chapterEditResource.submit(
|
||||
{},
|
||||
@@ -137,31 +192,16 @@ 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) => {
|
||||
@@ -169,11 +209,18 @@ watch(
|
||||
}
|
||||
)
|
||||
|
||||
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>
|
||||
|
||||
@@ -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"
|
||||
@@ -369,13 +373,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
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
|
||||
import json
|
||||
import frappe
|
||||
import zipfile
|
||||
import os
|
||||
import xml.etree.ElementTree as ET
|
||||
from frappe.translate import get_all_translations
|
||||
from frappe import _
|
||||
from frappe.query_builder import DocType
|
||||
@@ -10,6 +13,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 +880,70 @@ def give_dicussions_permission():
|
||||
"delete": 1,
|
||||
}
|
||||
).save(ignore_permissions=True)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def add_chapter(title, course, is_scorm_package, scorm_package):
|
||||
values = frappe._dict(
|
||||
{"title": title, "course": course, "is_scorm_package": is_scorm_package}
|
||||
)
|
||||
|
||||
scorm_package = frappe._dict(scorm_package)
|
||||
if is_scorm_package:
|
||||
package = frappe.get_doc("File", scorm_package.name)
|
||||
zip_path = package.get_full_path()
|
||||
|
||||
# Extract the zip file
|
||||
extract_path = frappe.get_site_path("public", "files", "scorm", course, title)
|
||||
zipfile.ZipFile(zip_path).extractall(extract_path)
|
||||
|
||||
values.update(
|
||||
{
|
||||
"scorm_package": scorm_package.name,
|
||||
"scorm_package_path": extract_path,
|
||||
"manifest_file": get_manifest_file(extract_path),
|
||||
"launch_file": get_launch_file(extract_path),
|
||||
}
|
||||
)
|
||||
|
||||
chapter = frappe.new_doc("Course Chapter")
|
||||
print(values.title)
|
||||
chapter.update(values)
|
||||
print(chapter.title)
|
||||
chapter.insert()
|
||||
|
||||
return chapter
|
||||
|
||||
|
||||
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)
|
||||
print(extract_path)
|
||||
|
||||
if manifest_file:
|
||||
with open(manifest_file) as file:
|
||||
data = file.read()
|
||||
print(data)
|
||||
dom = parseString(data)
|
||||
resource = dom.getElementsByTagName("resource")
|
||||
for res in resource:
|
||||
if 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
|
||||
|
||||
@@ -8,9 +8,16 @@
|
||||
"editable_grid": 1,
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"course",
|
||||
"column_break_3",
|
||||
"title",
|
||||
"column_break_3",
|
||||
"course",
|
||||
"scorm_section",
|
||||
"is_scorm_package",
|
||||
"scorm_package",
|
||||
"scorm_package_path",
|
||||
"column_break_dlnw",
|
||||
"manifest_file",
|
||||
"launch_file",
|
||||
"section_break_5",
|
||||
"lessons"
|
||||
],
|
||||
@@ -43,6 +50,49 @@
|
||||
"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
|
||||
}
|
||||
],
|
||||
"index_web_pages_for_search": 1,
|
||||
@@ -53,7 +103,7 @@
|
||||
"link_fieldname": "chapter"
|
||||
}
|
||||
],
|
||||
"modified": "2024-10-29 16:54:20.904683",
|
||||
"modified": "2024-11-11 16:25:45.586160",
|
||||
"modified_by": "Administrator",
|
||||
"module": "LMS",
|
||||
"name": "Course Chapter",
|
||||
|
||||
@@ -1128,7 +1128,7 @@ 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"],
|
||||
as_dict=True,
|
||||
)
|
||||
chapter_details["idx"] = chapter.idx
|
||||
|
||||
Reference in New Issue
Block a user