mirror of
https://github.com/frappe/lms.git
synced 2026-04-19 22:52:29 +03:00
feat: export course zip
This commit is contained in:
@@ -4,15 +4,19 @@
|
||||
class="sticky top-0 z-10 flex items-center justify-between border-b bg-surface-white px-3 py-2.5 sm:px-5"
|
||||
>
|
||||
<Breadcrumbs class="h-7" :items="breadcrumbs" />
|
||||
<div v-if="tabIndex == 2" class="flex items-center space-x-2">
|
||||
<div v-if="tabIndex == 2 && isAdmin" class="flex items-center space-x-2">
|
||||
<Badge v-if="childRef?.isDirty" theme="orange">
|
||||
{{ __('Not Saved') }}
|
||||
</Badge>
|
||||
<Button @click="childRef.trashCourse()">
|
||||
<template #icon>
|
||||
<Trash2 class="w-4 h-4 stroke-1.5" />
|
||||
<Dropdown :options="courseMenu" side="left">
|
||||
<template v-slot="{ open }">
|
||||
<Button>
|
||||
<template #icon>
|
||||
<Ellipsis class="w-4 h-4 stroke-1.5" />
|
||||
</template>
|
||||
</Button>
|
||||
</template>
|
||||
</Button>
|
||||
</Dropdown>
|
||||
<Button variant="solid" @click="childRef.submitCourse()">
|
||||
{{ __('Save') }}
|
||||
</Button>
|
||||
@@ -31,16 +35,26 @@
|
||||
<script setup>
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
createResource,
|
||||
Breadcrumbs,
|
||||
Button,
|
||||
call,
|
||||
createResource,
|
||||
Dropdown,
|
||||
Tabs,
|
||||
toast,
|
||||
usePageMeta,
|
||||
} from 'frappe-ui'
|
||||
import { computed, inject, markRaw, onMounted, ref, watch } from 'vue'
|
||||
import { sessionStore } from '@/stores/session'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { List, Settings2, Trash2, TrendingUp } from 'lucide-vue-next'
|
||||
import {
|
||||
Download,
|
||||
Ellipsis,
|
||||
List,
|
||||
Settings2,
|
||||
Trash2,
|
||||
TrendingUp,
|
||||
} from 'lucide-vue-next'
|
||||
import CourseOverview from '@/pages/Courses/CourseOverview.vue'
|
||||
import CourseDashboard from '@/pages/Courses/CourseDashboard.vue'
|
||||
import CourseForm from '@/pages/Courses/CourseForm.vue'
|
||||
@@ -139,6 +153,89 @@ const isAdmin = computed(() => {
|
||||
return user.data?.is_moderator || isInstructor()
|
||||
})
|
||||
|
||||
const exportCourse = async () => {
|
||||
/* call("lms.lms.api.export_course_as_zip", {
|
||||
course_name: course.data.name,
|
||||
}).then(data => {
|
||||
console.log(data)
|
||||
//download_course_zip(data)
|
||||
}).catch(error => {
|
||||
console.error("Error exporting course:", error)
|
||||
toast.error(__(error.messages?.[0] || error))
|
||||
}) */
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
'/api/method/lms.lms.api.export_course_as_zip',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
course_name: course.data.name,
|
||||
}),
|
||||
credentials: 'include',
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Download failed')
|
||||
}
|
||||
|
||||
const blob = await response.blob()
|
||||
|
||||
// Extract filename from header if present
|
||||
const disposition = response.headers.get('Content-Disposition')
|
||||
let filename = 'course.zip'
|
||||
|
||||
if (disposition && disposition.includes('filename=')) {
|
||||
filename = disposition.split('filename=')[1].replace(/"/g, '')
|
||||
}
|
||||
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = filename
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
|
||||
a.remove()
|
||||
window.URL.revokeObjectURL(url)
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
toast.error('Export failed')
|
||||
}
|
||||
}
|
||||
|
||||
const download_course_zip = (data) => {
|
||||
const a = document.createElement('a')
|
||||
a.href = data.export_url
|
||||
a.download = data.name
|
||||
a.click()
|
||||
}
|
||||
|
||||
const courseMenu = computed(() => {
|
||||
let options = [
|
||||
{
|
||||
label: __('Export'),
|
||||
onClick() {
|
||||
exportCourse()
|
||||
},
|
||||
icon: Download,
|
||||
},
|
||||
{
|
||||
label: __('Delete'),
|
||||
onClick() {
|
||||
childRef.value.trashCourse()
|
||||
},
|
||||
icon: Trash2,
|
||||
},
|
||||
]
|
||||
return options
|
||||
})
|
||||
|
||||
const breadcrumbs = computed(() => {
|
||||
let crumbs = [{ label: __('Courses'), route: { name: 'Courses' } }]
|
||||
crumbs.push({
|
||||
|
||||
215
frontend/src/pages/Courses/CourseImportModal.vue
Normal file
215
frontend/src/pages/Courses/CourseImportModal.vue
Normal file
@@ -0,0 +1,215 @@
|
||||
<template>
|
||||
<Dialog
|
||||
v-model="show"
|
||||
:options="{
|
||||
title: __('Import Course from ZIP'),
|
||||
}"
|
||||
>
|
||||
<template #body-content>
|
||||
<div class="text-p-base">
|
||||
<div
|
||||
v-if="!zip"
|
||||
@dragover.prevent
|
||||
@drop.prevent="(e) => uploadFile(e)"
|
||||
class="h-[100px] flex flex-col items-center justify-center bg-surface-gray-1 border border-dashed border-outline-gray-3 rounded-md"
|
||||
>
|
||||
<div v-if="!uploading" class="w-4/5 text-center">
|
||||
<UploadCloud
|
||||
class="size-6 stroke-1.5 text-ink-gray-6 mx-auto mb-2.5"
|
||||
/>
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
accept=".zip"
|
||||
class="hidden"
|
||||
@change="(e) => uploadFile(e)"
|
||||
/>
|
||||
<div class="leading-5 text-ink-gray-9">
|
||||
{{ __('Drag and drop a ZIP file, or upload from your') }}
|
||||
<span
|
||||
@click="openFileSelector"
|
||||
class="cursor-pointer font-semibold hover:underline"
|
||||
>
|
||||
{{ __('Device') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="uploading"
|
||||
class="w-4/5 lg:w-2/5 bg-surface-white border rounded-md p-2"
|
||||
>
|
||||
<div class="space-y-2">
|
||||
<div class="font-medium">
|
||||
{{ uploadingFile.name }}
|
||||
</div>
|
||||
<div class="text-ink-gray-6">
|
||||
{{ convertToKB(uploaded) }} of {{ convertToKB(total) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full bg-surface-gray-1 h-1 rounded-full mt-3">
|
||||
<div
|
||||
class="bg-surface-gray-7 h-1 rounded-full transition-all duration-500 ease-in-out"
|
||||
:style="`width: ${uploadProgress}%`"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="zip"
|
||||
class="h-[300px] flex items-center justify-center bg-surface-gray-1 border border-dashed border-outline-gray-3 rounded-md"
|
||||
>
|
||||
<div
|
||||
class="w-4/5 lg:w-2/5 bg-surface-white border rounded-md p-2 flex items-center justify-between items-center"
|
||||
>
|
||||
<div class="space-y-2">
|
||||
<!-- <div class="font-medium leading-5 text-ink-gray-9">
|
||||
{{ importFile.file_name || importFile.split("/").pop() }}
|
||||
</div>
|
||||
<div v-if="importFile.file_size" class="text-ink-gray-6">
|
||||
{{ convertToKB(importFile.file_size) }}
|
||||
</div> -->
|
||||
</div>
|
||||
<FeatherIcon
|
||||
name="trash-2"
|
||||
class="size-4 stroke-1.5 text-ink-red-3 cursor-pointer"
|
||||
@click="deleteFile"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<!-- <div class="text-ink-gray-7 text-xs mb-1">
|
||||
{{ __("Upload a ZIP file") }}
|
||||
</div>
|
||||
<FileUploader
|
||||
v-if="!zip"
|
||||
:fileTypes="['.zip']"
|
||||
:validateFile="(file: File) => validateFile(file, true, 'zip')"
|
||||
@success="(file: File) => (zip = file)"
|
||||
>
|
||||
<template v-slot="{ file, progress, uploading, openFileSelector }">
|
||||
<div class="mb-4">
|
||||
<Button @click="openFileSelector" :loading="uploading">
|
||||
{{
|
||||
uploading ? __('Uploading {0}%').format(progress) : __("Upload")
|
||||
}}
|
||||
</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-ink-gray-7" />
|
||||
</div>
|
||||
{{ zip }}
|
||||
<div class="flex flex-col">
|
||||
<span class="text-ink-gray-9">
|
||||
{{ chapter.scorm_package.file_name }}
|
||||
</span>
|
||||
<span class="text-sm text-ink-gray-4 mt-1">
|
||||
{{ getFileSize(chapter.scorm_package.file_size) }}
|
||||
</span>
|
||||
</div>
|
||||
<X
|
||||
@click="() => (chapter.scorm_package = null)"
|
||||
class="bg-surface-gray-3 rounded-md cursor-pointer stroke-1.5 w-5 h-5 p-1 ml-4"
|
||||
/>
|
||||
</div>
|
||||
</div> -->
|
||||
</div>
|
||||
</template>
|
||||
<template #actions>
|
||||
<div class="flex justify-end">
|
||||
<Button variant="solid">
|
||||
{{ __('Import') }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { Button, Dialog, FileUploadHandler, toast } from 'frappe-ui'
|
||||
import { computed, ref } from 'vue'
|
||||
import { UploadCloud } from 'lucide-vue-next'
|
||||
|
||||
const fileInput = ref<HTMLInputElement | null>(null)
|
||||
const show = defineModel<boolean>({ required: true, default: false })
|
||||
const zip = ref<File | null>(null)
|
||||
const uploaded = ref(0)
|
||||
const total = ref(0)
|
||||
const uploading = ref(false)
|
||||
const uploadingFile = ref<any | null>(null)
|
||||
|
||||
const openFileSelector = () => {
|
||||
fileInput.value?.click()
|
||||
}
|
||||
|
||||
const uploadProgress = computed(() => {
|
||||
if (total.value === 0) return 0
|
||||
return Math.floor((uploaded.value / total.value) * 100)
|
||||
})
|
||||
|
||||
const extractFile = (e: Event): File | null => {
|
||||
const inputFiles = (e.target as HTMLInputElement)?.files
|
||||
const dt = (e as DragEvent).dataTransfer?.files
|
||||
|
||||
return inputFiles?.[0] || dt?.[0] || null
|
||||
}
|
||||
|
||||
const validateFile = (file: File) => {
|
||||
const extension = file.name.split('.').pop()?.toLowerCase()
|
||||
if (extension !== 'zip') {
|
||||
toast.error('Please upload a valid ZIP file.')
|
||||
console.error('Please upload a valid ZIP file.')
|
||||
}
|
||||
return extension
|
||||
}
|
||||
|
||||
const uploadFile = (e: Event) => {
|
||||
const file = extractFile(e)
|
||||
if (!file) return
|
||||
|
||||
let fileType = validateFile(file)
|
||||
if (fileType !== 'zip') return
|
||||
|
||||
uploadingFile.value = file
|
||||
const uploader = new FileUploadHandler()
|
||||
|
||||
uploader.on('start', () => {
|
||||
uploading.value = true
|
||||
})
|
||||
|
||||
uploader.on('progress', (data: { uploaded: number; total: number }) => {
|
||||
uploaded.value = data.uploaded
|
||||
total.value = data.total
|
||||
console.log(uploaded.value, total.value)
|
||||
})
|
||||
|
||||
uploader.on('error', (error: any) => {
|
||||
uploading.value = false
|
||||
toast.error(error)
|
||||
console.error('File upload error:', error)
|
||||
})
|
||||
|
||||
uploader.on('finish', () => {
|
||||
uploading.value = false
|
||||
})
|
||||
uploader
|
||||
.upload(file, {
|
||||
private: 0,
|
||||
})
|
||||
.then((data: any) => {
|
||||
zip.value = data
|
||||
})
|
||||
.catch((error: any) => {
|
||||
console.error('File upload error:', error)
|
||||
})
|
||||
}
|
||||
|
||||
const deleteFile = () => {
|
||||
zip.value = null
|
||||
}
|
||||
|
||||
const convertToKB = (bytes: number) => {
|
||||
return (bytes / 1024).toFixed(2) + ' KB'
|
||||
}
|
||||
</script>
|
||||
@@ -8,25 +8,7 @@
|
||||
placement="right"
|
||||
side="bottom"
|
||||
v-if="canCreateCourse()"
|
||||
:options="[
|
||||
{
|
||||
label: __('New Course'),
|
||||
icon: 'book-open',
|
||||
onClick() {
|
||||
showCourseModal = true
|
||||
},
|
||||
},
|
||||
{
|
||||
label: __('Import Course'),
|
||||
icon: 'upload',
|
||||
onClick() {
|
||||
router.push({
|
||||
name: 'NewDataImport',
|
||||
params: { doctype: 'LMS Course' },
|
||||
})
|
||||
},
|
||||
},
|
||||
]"
|
||||
:options="courseMenu"
|
||||
>
|
||||
<template v-slot="{ open }">
|
||||
<Button variant="solid">
|
||||
@@ -113,6 +95,11 @@
|
||||
v-model="showCourseModal"
|
||||
:courses="courses"
|
||||
/>
|
||||
|
||||
<CourseImportModal
|
||||
v-if="showCourseImportModal"
|
||||
v-model="showCourseImportModal"
|
||||
/>
|
||||
</template>
|
||||
<script setup>
|
||||
import {
|
||||
@@ -135,6 +122,7 @@ import CourseCard from '@/components/CourseCard.vue'
|
||||
import EmptyState from '@/components/EmptyState.vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import NewCourseModal from '@/pages/Courses/NewCourseModal.vue'
|
||||
import CourseImportModal from '@/pages/Courses/CourseImportModal.vue'
|
||||
|
||||
const user = inject('$user')
|
||||
const dayjs = inject('$dayjs')
|
||||
@@ -155,6 +143,7 @@ const { brand } = sessionStore()
|
||||
const courseCount = ref(0)
|
||||
const router = useRouter()
|
||||
const showCourseModal = ref(false)
|
||||
const showCourseImportModal = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
setFiltersFromQuery()
|
||||
@@ -351,6 +340,35 @@ const courseTabs = computed(() => {
|
||||
return tabs
|
||||
})
|
||||
|
||||
const courseMenu = computed(() => {
|
||||
return [
|
||||
{
|
||||
label: __('New Course'),
|
||||
icon: 'book-open',
|
||||
onClick() {
|
||||
showCourseModal.value = true
|
||||
},
|
||||
},
|
||||
{
|
||||
label: __('Import via Data Import Tool'),
|
||||
icon: 'upload',
|
||||
onClick() {
|
||||
router.push({
|
||||
name: 'NewDataImport',
|
||||
params: { doctype: 'LMS Course' },
|
||||
})
|
||||
},
|
||||
},
|
||||
{
|
||||
label: __('Import via ZIP'),
|
||||
icon: 'folder-plus',
|
||||
onClick() {
|
||||
showCourseImportModal.value = true
|
||||
},
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
const breadcrumbs = computed(() => [
|
||||
{
|
||||
label: __('Courses'),
|
||||
|
||||
@@ -657,6 +657,8 @@ export const validateFile = async (
|
||||
return error(
|
||||
__('Only document file of type .doc or .docx are allowed.')
|
||||
)
|
||||
} else if (fileType == 'zip' && extension != 'zip') {
|
||||
return error(__('Only ZIP files are allowed.'))
|
||||
} else if (
|
||||
['image', 'video'].includes(fileType) &&
|
||||
!file.type.startsWith(`${fileType}/`)
|
||||
|
||||
195
lms/lms/api.py
195
lms/lms/api.py
@@ -3,10 +3,12 @@
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import shutil
|
||||
import tempfile
|
||||
import xml.etree.ElementTree as ET
|
||||
import zipfile
|
||||
from datetime import timedelta
|
||||
from datetime import date, datetime, timedelta
|
||||
from xml.dom.minidom import parseString
|
||||
|
||||
import frappe
|
||||
@@ -34,12 +36,10 @@ from lms.lms.utils import (
|
||||
LMS_ROLES,
|
||||
can_modify_batch,
|
||||
can_modify_course,
|
||||
get_average_rating,
|
||||
get_batch_details,
|
||||
get_course_details,
|
||||
get_field_meta,
|
||||
get_instructors,
|
||||
get_lesson_count,
|
||||
get_lms_route,
|
||||
has_course_instructor_role,
|
||||
has_evaluator_role,
|
||||
@@ -1218,18 +1218,18 @@ def prepare_heatmap_data(start_date: str, number_of_days: int, date_count: dict)
|
||||
last_seen_month = None
|
||||
sorted_dates = sorted(date_count.keys())
|
||||
|
||||
for date in sorted_dates:
|
||||
activity_count = date_count[date]
|
||||
day_of_week = get_datetime(date).strftime("%a")
|
||||
current_month = get_datetime(date).strftime("%b")
|
||||
column_index = get_week_difference(start_date, date)
|
||||
for date_value in sorted_dates:
|
||||
activity_count = date_count[date_value]
|
||||
day_of_week = get_datetime(date_value).strftime("%a")
|
||||
current_month = get_datetime(date_value).strftime("%b")
|
||||
column_index = get_week_difference(start_date, date_value)
|
||||
|
||||
if 0 <= column_index < week_count:
|
||||
heatmap_data[day_of_week].append(
|
||||
{
|
||||
"date": date,
|
||||
"date": date_value,
|
||||
"count": activity_count,
|
||||
"label": f"{activity_count} activities on {format_date(date, 'dd MMM')}",
|
||||
"label": f"{activity_count} activities on {format_date(date_value, 'dd MMM')}",
|
||||
}
|
||||
)
|
||||
|
||||
@@ -2353,3 +2353,178 @@ def search_users_by_role(txt: str = "", roles: str | list | None = None, page_le
|
||||
{"value": r.name, "description": r.full_name or r.name, "label": r.full_name or r.name}
|
||||
for r in results
|
||||
]
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def export_course_as_zip(course_name: str):
|
||||
if not can_modify_course(course_name):
|
||||
frappe.throw(_("You do not have permission to export this course."), frappe.PermissionError)
|
||||
course = frappe.get_doc("LMS Course", course_name)
|
||||
chapters = get_chapters_for_export(course.chapters)
|
||||
lessons = get_lessons_for_export(course_name)
|
||||
assets = get_course_assets(course, lessons)
|
||||
assessments = get_course_assessments(lessons)
|
||||
safe_time = frappe.utils.now_datetime().strftime("%Y%m%d_%H%M%S")
|
||||
zip_filename = f"{course.name}_{safe_time}_{secrets.token_hex(4)}.zip"
|
||||
create_course_zip(zip_filename, course, chapters, lessons, assets, assessments)
|
||||
|
||||
|
||||
def get_chapters_for_export(chapters: list):
|
||||
chapters_list = []
|
||||
for row in chapters:
|
||||
chapter = frappe.get_doc("Course Chapter", row.chapter)
|
||||
chapters_list.append(chapter)
|
||||
return chapters_list
|
||||
|
||||
|
||||
def get_lessons_for_export(course_name: str):
|
||||
lessons = frappe.get_all("Course Lesson", {"course": course_name}, pluck="name")
|
||||
lessons_list = []
|
||||
for lesson in lessons:
|
||||
lesson_doc = frappe.get_doc("Course Lesson", lesson)
|
||||
lessons_list.append(lesson_doc)
|
||||
return lessons_list
|
||||
|
||||
|
||||
def get_course_assessments(lessons):
|
||||
assessments = []
|
||||
for lesson in lessons:
|
||||
content = json.loads(lesson.content) if lesson.content else {}
|
||||
for block in content.get("blocks", []):
|
||||
block_type = block.get("type")
|
||||
if block_type in ("quiz", "assignment", "program"):
|
||||
data_field = "exercise" if block_type == "program" else block_type
|
||||
name = block.get("data", {}).get(data_field)
|
||||
doctype = (
|
||||
"LMS Quiz"
|
||||
if block_type == "quiz"
|
||||
else ("LMS Assignment" if block_type == "assignment" else "LMS Programming Exercise")
|
||||
)
|
||||
if frappe.db.exists(doctype, name):
|
||||
doc = frappe.get_doc(doctype, name)
|
||||
assessments.append(doc.as_dict())
|
||||
return assessments
|
||||
|
||||
|
||||
def get_course_assets(course, lessons):
|
||||
assets = []
|
||||
if course.image:
|
||||
assets.append(course.image)
|
||||
for lesson in lessons:
|
||||
content = json.loads(lesson.content) if lesson.content else {}
|
||||
for block in content.get("blocks", []):
|
||||
if block.get("type") == "upload":
|
||||
url = block.get("data", {}).get("file_url")
|
||||
assets.append(url)
|
||||
return assets
|
||||
|
||||
|
||||
def read_asset_content(url):
|
||||
try:
|
||||
file_doc = frappe.get_doc("File", {"file_url": url})
|
||||
file_path = file_doc.get_full_path()
|
||||
with open(file_path, "rb") as f:
|
||||
return f.read()
|
||||
except Exception:
|
||||
frappe.log_error(frappe.get_traceback(), f"Could not read asset: {url}")
|
||||
return None
|
||||
|
||||
|
||||
def create_course_zip(zip_filename, course, chapters, lessons, assets, assessments):
|
||||
try:
|
||||
tmp_path = os.path.join(tempfile.gettempdir(), zip_filename)
|
||||
build_course_zip(tmp_path, course, chapters, lessons, assets, assessments)
|
||||
final_path = move_zip_to_public(tmp_path, zip_filename)
|
||||
schedule_file_deletion(final_path, delay_seconds=600) # 10 minutes
|
||||
serve_zip(final_path, zip_filename)
|
||||
except Exception as e:
|
||||
print("Error creating ZIP file:", e)
|
||||
return None
|
||||
|
||||
|
||||
def build_course_zip(tmp_path, course, chapters, lessons, assets, assessments):
|
||||
with zipfile.ZipFile(tmp_path, "w", compression=zipfile.ZIP_DEFLATED) as zip_file:
|
||||
write_course_json(zip_file, course)
|
||||
write_chapters_json(zip_file, chapters)
|
||||
write_lessons_json(zip_file, lessons)
|
||||
write_assessments_json(zip_file, assessments)
|
||||
write_assets(zip_file, assets)
|
||||
|
||||
|
||||
def write_course_json(zip_file, course):
|
||||
zip_file.writestr("course.json", frappe_json_dumps(course.as_dict()))
|
||||
|
||||
|
||||
def write_chapters_json(zip_file, chapters):
|
||||
for chapter in chapters:
|
||||
chapter_data = chapter.as_dict()
|
||||
chapter_json = frappe_json_dumps(chapter_data)
|
||||
zip_file.writestr(f"chapters/{chapter.name}.json", chapter_json)
|
||||
|
||||
|
||||
def write_lessons_json(zip_file, lessons):
|
||||
for lesson in lessons:
|
||||
lesson_data = lesson.as_dict()
|
||||
lesson_json = frappe_json_dumps(lesson_data)
|
||||
zip_file.writestr(f"lessons/{lesson.name}.json", lesson_json)
|
||||
|
||||
|
||||
def write_assessments_json(zip_file, assessments):
|
||||
for assessment in assessments:
|
||||
assessment_data = assessment
|
||||
assessment_json = frappe_json_dumps(assessment_data)
|
||||
zip_file.writestr(
|
||||
f"assessments/{assessment['doctype'].lower()}_{assessment['name']}.json", assessment_json
|
||||
)
|
||||
|
||||
|
||||
def write_assets(zip_file, assets):
|
||||
assets = list(set(assets))
|
||||
for asset in assets:
|
||||
try:
|
||||
file_doc = frappe.get_doc("File", {"file_url": asset})
|
||||
file_path = os.path.abspath(file_doc.get_full_path())
|
||||
zip_file.write(file_path, f"assets/{os.path.basename(asset)}")
|
||||
except Exception:
|
||||
frappe.log_error(frappe.get_traceback(), f"Could not add asset: {asset}")
|
||||
continue
|
||||
|
||||
|
||||
def move_zip_to_public(tmp_path, zip_filename):
|
||||
final_path = os.path.join(frappe.get_site_path("public", "files"), zip_filename)
|
||||
shutil.move(tmp_path, final_path)
|
||||
return final_path
|
||||
|
||||
|
||||
def serve_zip(final_path, zip_filename):
|
||||
with open(final_path, "rb") as f:
|
||||
frappe.local.response.filename = zip_filename
|
||||
frappe.local.response.filecontent = f.read()
|
||||
frappe.local.response.type = "download"
|
||||
frappe.local.response.content_type = "application/zip"
|
||||
|
||||
|
||||
def schedule_file_deletion(file_path, delay_seconds=600):
|
||||
import threading
|
||||
|
||||
def delete():
|
||||
try:
|
||||
if os.path.exists(file_path):
|
||||
os.remove(file_path)
|
||||
except Exception as e:
|
||||
frappe.log_error(f"Error deleting exported file {file_path}: {e}")
|
||||
|
||||
timer = threading.Timer(delay_seconds, delete)
|
||||
timer.daemon = True
|
||||
timer.start()
|
||||
|
||||
|
||||
def frappe_json_dumps(data):
|
||||
def default(obj):
|
||||
try:
|
||||
if isinstance(obj, (datetime | date)):
|
||||
return str(obj)
|
||||
except Exception as e:
|
||||
frappe.log_error(f"Error serializing object {obj}: {e}")
|
||||
|
||||
return json.dumps(data, indent=4, default=default)
|
||||
|
||||
@@ -21,6 +21,10 @@
|
||||
"column_break_clsh",
|
||||
"enable_negative_marking",
|
||||
"marks_to_cut",
|
||||
"proctoring_section",
|
||||
"enable_proctoring",
|
||||
"column_break_hmdx",
|
||||
"maximum_violations_allowed",
|
||||
"section_break_sbjx",
|
||||
"questions",
|
||||
"section_break_3",
|
||||
@@ -135,7 +139,8 @@
|
||||
{
|
||||
"fieldname": "duration",
|
||||
"fieldtype": "Data",
|
||||
"label": "Duration (in minutes)"
|
||||
"label": "Duration (in minutes)",
|
||||
"mandatory_depends_on": "enable_proctoring"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
@@ -149,6 +154,28 @@
|
||||
"fieldname": "marks_to_cut",
|
||||
"fieldtype": "Int",
|
||||
"label": "Marks To Cut"
|
||||
},
|
||||
{
|
||||
"fieldname": "proctoring_section",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Proctoring"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "enable_proctoring",
|
||||
"fieldtype": "Check",
|
||||
"label": "Enable Proctoring"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_hmdx",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"depends_on": "enable_proctoring",
|
||||
"fieldname": "maximum_violations_allowed",
|
||||
"fieldtype": "Int",
|
||||
"label": "Maximum Violations Allowed",
|
||||
"mandatory_depends_on": "enable_proctoring"
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
@@ -159,7 +186,7 @@
|
||||
"link_fieldname": "quiz"
|
||||
}
|
||||
],
|
||||
"modified": "2025-06-27 20:00:15.660323",
|
||||
"modified": "2026-03-25 16:23:25.258120",
|
||||
"modified_by": "sayali@frappe.io",
|
||||
"module": "LMS",
|
||||
"name": "LMS Quiz",
|
||||
@@ -209,6 +236,18 @@
|
||||
"report": 1,
|
||||
"role": "LMS Student",
|
||||
"share": 1
|
||||
},
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "Batch Evaluator",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
}
|
||||
],
|
||||
"row_format": "Dynamic",
|
||||
|
||||
Reference in New Issue
Block a user