feat: export course zip

This commit is contained in:
Jannat Patel
2026-03-25 16:29:56 +05:30
parent 6e852cb86f
commit aaa866e3ff
6 changed files with 585 additions and 39 deletions

View File

@@ -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({

View 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>

View File

@@ -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'),

View File

@@ -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}/`)

View File

@@ -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)

View File

@@ -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",