mirror of
https://github.com/frappe/lms.git
synced 2026-04-19 22:52:29 +03:00
feat: import course zip
This commit is contained in:
@@ -247,7 +247,7 @@ const evaluationResource = createResource({
|
||||
member: props.event.member,
|
||||
course: props.event.course,
|
||||
batch_name: props.event.batch_name,
|
||||
date: props.event.date,
|
||||
date_value: props.event.date,
|
||||
start_time: props.event.start_time,
|
||||
end_time: props.event.end_time,
|
||||
status: evaluation.status,
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
{{ uploadingFile.name }}
|
||||
</div>
|
||||
<div class="text-ink-gray-6">
|
||||
{{ convertToKB(uploaded) }} of {{ convertToKB(total) }}
|
||||
{{ convertToMB(uploaded) }} of {{ convertToMB(total) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full bg-surface-gray-1 h-1 rounded-full mt-3">
|
||||
@@ -56,70 +56,30 @@
|
||||
</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"
|
||||
class="h-[100px] 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"
|
||||
class="w-4/5 lg:w-3/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 class="font-medium leading-5 text-ink-gray-9">
|
||||
{{ zip.file_name || zip.name }}
|
||||
</div>
|
||||
<div v-if="zip.file_size" class="text-ink-gray-6">
|
||||
{{ convertToMB(zip.file_size) }}
|
||||
</div>
|
||||
</div>
|
||||
<FeatherIcon
|
||||
name="trash-2"
|
||||
<Trash2
|
||||
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">
|
||||
<Button variant="solid" @click="importZip">
|
||||
{{ __('Import') }}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -127,13 +87,14 @@
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { Button, Dialog, FileUploadHandler, toast } from 'frappe-ui'
|
||||
import { Button, call, Dialog, FileUploadHandler, toast } from 'frappe-ui'
|
||||
import { computed, ref } from 'vue'
|
||||
import { UploadCloud } from 'lucide-vue-next'
|
||||
import { Trash, UploadCloud } from 'lucide-vue-next'
|
||||
import { Trash2 } 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 zip = ref<any | null>(null)
|
||||
const uploaded = ref(0)
|
||||
const total = ref(0)
|
||||
const uploading = ref(false)
|
||||
@@ -205,11 +166,27 @@ const uploadFile = (e: Event) => {
|
||||
})
|
||||
}
|
||||
|
||||
const importZip = () => {
|
||||
if (!zip.value) return
|
||||
call('lms.lms.api.import_course_as_zip', {
|
||||
zip_file_path: zip.value.file_url,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success('Course imported successfully!')
|
||||
show.value = false
|
||||
deleteFile()
|
||||
})
|
||||
.catch((error: any) => {
|
||||
toast.error('Error importing course: ' + error.message)
|
||||
console.error('Error importing course:', error)
|
||||
})
|
||||
}
|
||||
|
||||
const deleteFile = () => {
|
||||
zip.value = null
|
||||
}
|
||||
|
||||
const convertToKB = (bytes: number) => {
|
||||
return (bytes / 1024).toFixed(2) + ' KB'
|
||||
const convertToMB = (bytes: number) => {
|
||||
return (bytes / 1024 / 1024).toFixed(2) + ' MB'
|
||||
}
|
||||
</script>
|
||||
|
||||
198
lms/lms/api.py
198
lms/lms/api.py
@@ -31,6 +31,7 @@ from frappe.utils import (
|
||||
from frappe.utils.response import Response
|
||||
from pypika import functions as fn
|
||||
|
||||
from lms.lms.course_import_export import export_course_zip, import_course_zip
|
||||
from lms.lms.doctype.course_lesson.course_lesson import save_progress
|
||||
from lms.lms.utils import (
|
||||
LMS_ROLES,
|
||||
@@ -663,7 +664,7 @@ def check_app_permission():
|
||||
def save_evaluation_details(
|
||||
member: str,
|
||||
course: str,
|
||||
date: str,
|
||||
date_value: str,
|
||||
start_time: str,
|
||||
end_time: str,
|
||||
status: str,
|
||||
@@ -679,7 +680,7 @@ def save_evaluation_details(
|
||||
evaluation = frappe.db.exists("LMS Certificate Evaluation", {"member": member, "course": course})
|
||||
|
||||
details = {
|
||||
"date": date,
|
||||
"date": date_value,
|
||||
"start_time": start_time,
|
||||
"end_time": end_time,
|
||||
"status": status,
|
||||
@@ -1205,9 +1206,9 @@ def fetch_activity_data(member: str, start_date: str):
|
||||
|
||||
def count_dates(data: list, date_count: dict):
|
||||
for entry in data:
|
||||
date = format_date(entry.creation, "YYYY-MM-dd")
|
||||
if date in date_count:
|
||||
date_count[date] += 1
|
||||
date_value = format_date(entry.creation, "YYYY-MM-dd")
|
||||
if date_value in date_count:
|
||||
date_count[date_value] += 1
|
||||
|
||||
|
||||
def prepare_heatmap_data(start_date: str, number_of_days: int, date_count: dict):
|
||||
@@ -1380,9 +1381,9 @@ def cancel_evaluation(evaluation: dict):
|
||||
|
||||
for event in events:
|
||||
info = frappe.db.get_value("Event", event.parent, ["starts_on", "subject"], as_dict=1)
|
||||
date = str(info.starts_on).split(" ")[0]
|
||||
date_value = str(info.starts_on).split(" ")[0]
|
||||
|
||||
if date == str(evaluation.date.format("YYYY-MM-DD")) and evaluation.member_name in info.subject:
|
||||
if date_value == str(evaluation.date.format("YYYY-MM-DD")) and evaluation.member_name in info.subject:
|
||||
communication = frappe.db.get_value(
|
||||
"Communication",
|
||||
{"reference_doctype": "Event", "reference_name": event.parent},
|
||||
@@ -2259,7 +2260,7 @@ def get_course_programming_exercise_progress(course: str, member: str):
|
||||
return submissions
|
||||
|
||||
|
||||
def get_assessment_from_lesson(course: str, assessmentType: str):
|
||||
def get_assessment_from_lesson(course: str, assessment_type: str):
|
||||
assessments = []
|
||||
lessons = frappe.get_all("Course Lesson", {"course": course}, ["name", "title", "content"])
|
||||
|
||||
@@ -2267,10 +2268,10 @@ def get_assessment_from_lesson(course: str, assessmentType: str):
|
||||
if lesson.content:
|
||||
content = json.loads(lesson.content)
|
||||
for block in content.get("blocks", []):
|
||||
if block.get("type") == assessmentType:
|
||||
data_field = "exercise" if assessmentType == "program" else assessmentType
|
||||
quiz_name = block.get("data", {}).get(data_field)
|
||||
assessments.append(quiz_name)
|
||||
if block.get("type") == assessment_type:
|
||||
data_field = "exercise" if assessment_type == "program" else assessment_type
|
||||
assessment_name = block.get("data", {}).get(data_field)
|
||||
assessments.append(assessment_name)
|
||||
|
||||
return assessments
|
||||
|
||||
@@ -2359,172 +2360,11 @@ def search_users_by_role(txt: str = "", roles: str | list | None = None, page_le
|
||||
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)
|
||||
|
||||
export_course_zip(course_name)
|
||||
|
||||
|
||||
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)
|
||||
@frappe.whitelist()
|
||||
def import_course_as_zip(zip_file_path):
|
||||
frappe.only_for(["Moderator", "Course Creator"])
|
||||
import_course_zip(zip_file_path)
|
||||
|
||||
607
lms/lms/course_import_export.py
Normal file
607
lms/lms/course_import_export.py
Normal file
@@ -0,0 +1,607 @@
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import shutil
|
||||
import tempfile
|
||||
import zipfile
|
||||
from datetime import date, datetime, timedelta
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
|
||||
|
||||
def export_course_zip(course_name):
|
||||
course = frappe.get_doc("LMS Course", course_name)
|
||||
chapters = get_chapters_for_export(course.chapters)
|
||||
lessons = get_lessons_for_export(course_name)
|
||||
instructors = get_course_instructors(course)
|
||||
evaluator = get_course_evaluator(course)
|
||||
assets = get_course_assets(course, lessons, instructors, evaluator)
|
||||
assessments, questions, test_cases = 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,
|
||||
questions,
|
||||
test_cases,
|
||||
instructors,
|
||||
evaluator,
|
||||
)
|
||||
|
||||
|
||||
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, questions, test_cases = [], [], []
|
||||
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())
|
||||
if doctype == "LMS Quiz":
|
||||
for q in doc.questions:
|
||||
question_doc = frappe.get_doc("LMS Question", q.question)
|
||||
questions.append(question_doc.as_dict())
|
||||
if doctype == "LMS Programming Exercise":
|
||||
for tc in doc.test_cases:
|
||||
test_case_doc = frappe.get_doc("LMS Test Case", tc.name)
|
||||
test_cases.append(test_case_doc.as_dict())
|
||||
|
||||
return assessments, questions, test_cases
|
||||
|
||||
|
||||
def get_course_instructors(course):
|
||||
users = []
|
||||
for instructor in course.instructors:
|
||||
user_info = frappe.db.get_value(
|
||||
"User",
|
||||
instructor.instructor,
|
||||
["name", "full_name", "first_name", "last_name", "email", "user_image"],
|
||||
as_dict=True,
|
||||
)
|
||||
if user_info:
|
||||
users.append(user_info)
|
||||
return users
|
||||
|
||||
|
||||
def get_course_evaluator(course):
|
||||
evaluators = []
|
||||
if course.evaluator and frappe.db.exists("Course Evaluator", course.evaluator):
|
||||
evaluator_info = frappe.get_doc("Course Evaluator", course.evaluator)
|
||||
evaluators.append(evaluator_info)
|
||||
return evaluators
|
||||
|
||||
|
||||
def get_course_assets(course, lessons, instructors, evaluator):
|
||||
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)
|
||||
for instructor in instructors:
|
||||
if instructor.get("user_image"):
|
||||
assets.append(instructor["user_image"])
|
||||
if len(evaluator):
|
||||
assets.append(evaluator[0].user_image)
|
||||
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,
|
||||
questions,
|
||||
test_cases,
|
||||
instructors,
|
||||
evaluator,
|
||||
):
|
||||
try:
|
||||
tmp_path = os.path.join(tempfile.gettempdir(), zip_filename)
|
||||
build_course_zip(
|
||||
tmp_path,
|
||||
course,
|
||||
chapters,
|
||||
lessons,
|
||||
assets,
|
||||
assessments,
|
||||
questions,
|
||||
test_cases,
|
||||
instructors,
|
||||
evaluator,
|
||||
)
|
||||
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, questions, test_cases, instructors, evaluator
|
||||
):
|
||||
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, questions, test_cases)
|
||||
write_assets(zip_file, assets)
|
||||
write_instructors_json(zip_file, instructors)
|
||||
write_evaluator_json(zip_file, evaluator)
|
||||
|
||||
|
||||
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, questions, test_cases):
|
||||
for question in questions:
|
||||
question_json = frappe_json_dumps(question)
|
||||
zip_file.writestr(f"assessments/questions/{question.name}.json", question_json)
|
||||
|
||||
for test_case in test_cases:
|
||||
test_case_json = frappe_json_dumps(test_case)
|
||||
zip_file.writestr(f"assessments/test_cases/{test_case.name}.json", test_case_json)
|
||||
|
||||
for assessment in assessments:
|
||||
assessment_json = frappe_json_dumps(assessment)
|
||||
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 write_instructors_json(zip_file, instructors):
|
||||
instructors_json = frappe_json_dumps(instructors)
|
||||
zip_file.writestr("instructors.json", instructors_json)
|
||||
|
||||
|
||||
def write_evaluator_json(zip_file, evaluator):
|
||||
if not len(evaluator):
|
||||
return
|
||||
evaluator_json = frappe_json_dumps(evaluator[0].as_dict())
|
||||
zip_file.writestr("evaluator.json", evaluator_json)
|
||||
|
||||
|
||||
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 | timedelta)):
|
||||
return str(obj)
|
||||
except Exception as e:
|
||||
frappe.log_error(f"Error serializing object {obj}: {e}")
|
||||
|
||||
return json.dumps(data, indent=4, default=default)
|
||||
|
||||
|
||||
def import_course_zip(zip_file_path):
|
||||
zip_file_path = zip_file_path.lstrip("/")
|
||||
actual_path = frappe.get_site_path(zip_file_path)
|
||||
with zipfile.ZipFile(actual_path, "r") as zip_file:
|
||||
course_data = read_json_from_zip(zip_file, "course.json")
|
||||
if not course_data:
|
||||
frappe.throw(_("Invalid course ZIP: Missing course.json"))
|
||||
|
||||
create_assets(zip_file)
|
||||
create_user_for_instructors(zip_file)
|
||||
create_evaluator(zip_file)
|
||||
course_doc = create_course_doc(course_data)
|
||||
chapter_docs = create_chapter_docs(zip_file, course_doc.name)
|
||||
create_assessment_docs(zip_file)
|
||||
create_lesson_docs(zip_file, course_doc.name, chapter_docs)
|
||||
save_course_structure(zip_file, course_doc, chapter_docs)
|
||||
|
||||
|
||||
def read_json_from_zip(zip_file, filename):
|
||||
try:
|
||||
with zip_file.open(filename) as f:
|
||||
return json.load(f)
|
||||
except Exception as e:
|
||||
frappe.log_error(f"Error reading {filename} from ZIP: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def create_user_for_instructors(zip_file):
|
||||
instructors = read_json_from_zip(zip_file, "instructors.json")
|
||||
if not instructors:
|
||||
return
|
||||
for instructor in instructors:
|
||||
if not frappe.db.exists("User", instructor["email"]):
|
||||
create_user(instructor)
|
||||
|
||||
|
||||
def create_user(user):
|
||||
user_doc = frappe.new_doc("User")
|
||||
user_doc.email = user["email"]
|
||||
user_doc.first_name = user["first_name"] if user.get("first_name") else user["full_name"].split()[0]
|
||||
user_doc.last_name = (
|
||||
user["last_name"]
|
||||
if user.get("last_name")
|
||||
else " ".join(user["full_name"].split()[1:])
|
||||
if len(user["full_name"].split()) > 1
|
||||
else ""
|
||||
)
|
||||
user_doc.full_name = (
|
||||
user["full_name"] if user.get("full_name") else f"{user_doc.first_name} {user_doc.last_name}".strip()
|
||||
)
|
||||
user_doc.user_image = user.get("user_image")
|
||||
user_doc.insert(ignore_permissions=True)
|
||||
|
||||
|
||||
def create_evaluator(zip_file):
|
||||
evaluator_data = read_json_from_zip(zip_file, "evaluator.json")
|
||||
if not evaluator_data:
|
||||
return
|
||||
if not frappe.db.exists("User", evaluator_data["evaluator"]):
|
||||
create_user(evaluator_data)
|
||||
|
||||
if not frappe.db.exists("Course Evaluator", evaluator_data["name"]):
|
||||
evaluator_doc = frappe.new_doc("Course Evaluator")
|
||||
evaluator_doc.update(evaluator_data)
|
||||
evaluator_doc.insert(ignore_permissions=True)
|
||||
|
||||
|
||||
def get_course_fields():
|
||||
return [
|
||||
"title",
|
||||
"tags",
|
||||
"image",
|
||||
"video_link",
|
||||
"card_gradient",
|
||||
"short_introduction",
|
||||
"description",
|
||||
"published",
|
||||
"upcoming",
|
||||
"featured",
|
||||
"disable_self_learning",
|
||||
"published_on",
|
||||
"category",
|
||||
"evaluator",
|
||||
"timezone",
|
||||
"paid_course",
|
||||
"paid_certificate",
|
||||
"course_price",
|
||||
"currency",
|
||||
"amount_usd",
|
||||
"enable_certification",
|
||||
]
|
||||
|
||||
|
||||
def add_data_to_course(course_doc, course_data):
|
||||
for field in get_course_fields():
|
||||
if field in course_data:
|
||||
course_doc.set(field, course_data[field])
|
||||
|
||||
|
||||
def add_instructors_to_course(course_doc, course_data):
|
||||
instructors = course_data.get("instructors", [])
|
||||
for instructor in instructors:
|
||||
course_doc.append("instructors", {"instructor": instructor["instructor"]})
|
||||
|
||||
|
||||
def verify_category(category_name):
|
||||
if category_name and not frappe.db.exists("LMS Category", category_name):
|
||||
category = frappe.new_doc("LMS Category")
|
||||
category.category = category_name
|
||||
category.insert(ignore_permissions=True)
|
||||
|
||||
|
||||
def create_course_doc(course_data):
|
||||
course_doc = frappe.new_doc("LMS Course")
|
||||
add_instructors_to_course(course_doc, course_data)
|
||||
verify_category(course_data.get("category"))
|
||||
course_data.pop("instructors", None)
|
||||
course_data.pop("chapters", None)
|
||||
add_data_to_course(course_doc, course_data)
|
||||
course_doc.insert(ignore_permissions=True)
|
||||
return course_doc
|
||||
|
||||
|
||||
def create_chapter_docs(zip_file, course_name):
|
||||
chapter_docs = []
|
||||
for file in zip_file.namelist():
|
||||
if file.startswith("chapters/") and file.endswith(".json"):
|
||||
chapter_data = read_json_from_zip(zip_file, file)
|
||||
if chapter_data:
|
||||
chapter_doc = frappe.new_doc("Course Chapter")
|
||||
chapter_data.pop("lessons", None)
|
||||
chapter_doc.update(chapter_data)
|
||||
chapter_doc.course = course_name
|
||||
chapter_doc.insert(ignore_permissions=True)
|
||||
chapter_docs.append(chapter_doc)
|
||||
return chapter_docs
|
||||
|
||||
|
||||
def get_chapter_name_for_lesson(zip_file, lesson_data, chapter_docs):
|
||||
for file in zip_file.namelist():
|
||||
if file.startswith("chapters/") and file.endswith(".json"):
|
||||
chapter_data = read_json_from_zip(zip_file, file)
|
||||
if chapter_data.get("name") == lesson_data.get("chapter"):
|
||||
title = chapter_data.get("title")
|
||||
chapter_doc = next((c for c in chapter_docs if c.title == title), None)
|
||||
if chapter_doc:
|
||||
return chapter_doc.name
|
||||
return None
|
||||
|
||||
|
||||
def get_assessment_map():
|
||||
return {"quiz": "LMS Quiz", "assignment": "LMS Assignment", "program": "LMS Programming Exercise"}
|
||||
|
||||
|
||||
def get_assessment_title(zip_file, assessment_name, assessment_type):
|
||||
assessment_map = get_assessment_map()
|
||||
file_name = f"assessments/{assessment_map.get(assessment_type, '').lower()}_{assessment_name}.json"
|
||||
try:
|
||||
with zip_file.open(file_name) as f:
|
||||
assessment_data = json.load(f)
|
||||
return assessment_data.get("title")
|
||||
except Exception as e:
|
||||
frappe.log_error(f"Error reading {file_name} from ZIP: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def replace_assessment_names(zip_file, content):
|
||||
assessment_types = ["quiz", "assignment", "program"]
|
||||
content = json.loads(content)
|
||||
for block in content.get("blocks", []):
|
||||
if block.get("type") in assessment_types:
|
||||
data_field = "exercise" if block.get("type") == "program" else block.get("type")
|
||||
assessment_name = block.get("data", {}).get(data_field)
|
||||
assessment_title = get_assessment_title(zip_file, assessment_name, block.get("type"))
|
||||
doctype = get_assessment_map().get(block.get("type"))
|
||||
current_assessment_name = frappe.db.get_value(doctype, {"title": assessment_title}, "name")
|
||||
if current_assessment_name:
|
||||
block["data"][data_field] = current_assessment_name
|
||||
return json.dumps(content)
|
||||
|
||||
|
||||
def replace_assets(zip_file, content):
|
||||
content = json.loads(content)
|
||||
for block in content.get("blocks", []):
|
||||
if block.get("type") == "upload":
|
||||
asset_url = block.get("data", {}).get("file_url")
|
||||
if asset_url:
|
||||
asset_name = asset_url.split("/")[-1]
|
||||
current_asset_url = frappe.db.get_value("LMS Asset", {"file_name": asset_name}, "file_url")
|
||||
if current_asset_url:
|
||||
block["data"]["url"] = current_asset_url
|
||||
|
||||
|
||||
def replace_values_in_content(zip_file, content):
|
||||
return replace_assessment_names(zip_file, content)
|
||||
# replace_assets(zip_file, content)
|
||||
|
||||
|
||||
def create_lesson_docs(zip_file, course_name, chapter_docs):
|
||||
lesson_docs = []
|
||||
for file in zip_file.namelist():
|
||||
if file.startswith("lessons/") and file.endswith(".json"):
|
||||
lesson_data = read_json_from_zip(zip_file, file)
|
||||
if lesson_data:
|
||||
lesson_doc = frappe.new_doc("Course Lesson")
|
||||
lesson_doc.update(lesson_data)
|
||||
lesson_doc.course = course_name
|
||||
lesson_doc.chapter = get_chapter_name_for_lesson(zip_file, lesson_data, chapter_docs)
|
||||
lesson_doc.content = (
|
||||
replace_values_in_content(zip_file, lesson_doc.content) if lesson_doc.content else None
|
||||
)
|
||||
lesson_doc.insert(ignore_permissions=True)
|
||||
lesson_docs.append(lesson_doc)
|
||||
return lesson_docs
|
||||
|
||||
|
||||
def create_question_doc(zip_file, file):
|
||||
question_data = read_json_from_zip(zip_file, file)
|
||||
if question_data:
|
||||
doc = frappe.new_doc("LMS Question")
|
||||
doc.update(question_data)
|
||||
doc.insert(ignore_permissions=True)
|
||||
|
||||
|
||||
def create_test_case_doc(zip_file, file):
|
||||
test_case_data = read_json_from_zip(zip_file, file)
|
||||
if test_case_data:
|
||||
doc = frappe.new_doc("LMS Test Case")
|
||||
doc.update(test_case_data)
|
||||
doc.insert(ignore_permissions=True)
|
||||
|
||||
|
||||
def add_questions_to_quiz(quiz_doc, questions):
|
||||
for question in questions:
|
||||
question_detail = question["question_detail"]
|
||||
question_name = frappe.db.get_value("LMS Question", {"question": question_detail}, "name")
|
||||
if question_name:
|
||||
quiz_doc.append("questions", {"question": question_name})
|
||||
|
||||
|
||||
def create_assessment_docs(zip_file):
|
||||
for file in zip_file.namelist():
|
||||
if file.startswith("assessments/questions/") and file.endswith(".json"):
|
||||
create_question_doc(zip_file, file)
|
||||
elif file.startswith("assessments/test_cases/") and file.endswith(".json"):
|
||||
create_test_case_doc(zip_file, file)
|
||||
|
||||
for file in zip_file.namelist():
|
||||
if (
|
||||
file.startswith("assessments/")
|
||||
and file.endswith(".json")
|
||||
and not file.startswith("assessments/questions/")
|
||||
and not file.startswith("assessments/test_cases/")
|
||||
):
|
||||
assessment_data = read_json_from_zip(zip_file, file)
|
||||
if not assessment_data:
|
||||
continue
|
||||
assessment_data.pop("lesson", None)
|
||||
assessment_data.pop("course", None)
|
||||
doctype = assessment_data.get("doctype")
|
||||
if doctype in ("LMS Quiz", "LMS Assignment", "LMS Programming Exercise") and not frappe.db.exists(
|
||||
doctype, assessment_data.get("name")
|
||||
):
|
||||
questions = assessment_data.pop("questions", [])
|
||||
test_cases = assessment_data.pop("test_cases", [])
|
||||
doc = frappe.new_doc(doctype)
|
||||
doc.update(assessment_data)
|
||||
if doctype == "LMS Quiz":
|
||||
add_questions_to_quiz(doc, questions)
|
||||
elif doctype == "LMS Programming Exercise":
|
||||
for row in test_cases:
|
||||
doc.append(
|
||||
"test_cases", {"input": row["input"], "expected_output": row["expected_output"]}
|
||||
)
|
||||
doc.insert(ignore_permissions=True)
|
||||
|
||||
|
||||
def create_assets(zip_file):
|
||||
for file in zip_file.namelist():
|
||||
if file.startswith("assets/") and not file.endswith("/"):
|
||||
try:
|
||||
with zip_file.open(file) as f:
|
||||
content = f.read()
|
||||
asset_name = file.split("/")[-1]
|
||||
if not frappe.db.exists("File", {"file_name": asset_name}):
|
||||
asset_doc = frappe.new_doc("File")
|
||||
asset_doc.file_name = asset_name
|
||||
asset_doc.content = content
|
||||
asset_doc.insert(ignore_permissions=True)
|
||||
except Exception as e:
|
||||
frappe.log_error(f"Error processing asset {file}: {e}")
|
||||
|
||||
|
||||
def get_lesson_title(zip_file, lesson_name):
|
||||
for file in zip_file.namelist():
|
||||
if file.startswith("lessons/") and file.endswith(".json"):
|
||||
lesson_data = read_json_from_zip(zip_file, file)
|
||||
if lesson_data.get("name") == lesson_name:
|
||||
return lesson_data.get("title")
|
||||
return None
|
||||
|
||||
|
||||
def add_lessons_to_chapters(zip_file, course_name, chapter_docs):
|
||||
for file in zip_file.namelist():
|
||||
if file.startswith("chapters/") and file.endswith(".json"):
|
||||
chapter_data = read_json_from_zip(zip_file, file)
|
||||
chapter_doc = next((c for c in chapter_docs if c.title == chapter_data.get("title")), None)
|
||||
if not chapter_doc:
|
||||
continue
|
||||
for lesson in chapter_data.get("lessons", []):
|
||||
lesson_title = get_lesson_title(zip_file, lesson["lesson"])
|
||||
lesson_name = frappe.db.get_value(
|
||||
"Course Lesson", {"title": lesson_title, "course": course_name}, "name"
|
||||
)
|
||||
if lesson_name:
|
||||
chapter_doc.append("lessons", {"lesson": lesson_name})
|
||||
chapter_doc.save(ignore_permissions=True)
|
||||
|
||||
|
||||
def add_chapter_to_course(course_doc, chapter_docs):
|
||||
course_doc.reload()
|
||||
for chapter_doc in chapter_docs:
|
||||
course_doc.append("chapters", {"chapter": chapter_doc.name})
|
||||
course_doc.save(ignore_permissions=True)
|
||||
|
||||
|
||||
def save_course_structure(zip_file, course_doc, chapter_docs):
|
||||
add_chapter_to_course(course_doc, chapter_docs)
|
||||
add_lessons_to_chapters(zip_file, course_doc.name, chapter_docs)
|
||||
@@ -10,9 +10,9 @@
|
||||
"field_order": [
|
||||
"title",
|
||||
"include_in_preview",
|
||||
"is_scorm_package",
|
||||
"column_break_4",
|
||||
"chapter",
|
||||
"is_scorm_package",
|
||||
"course",
|
||||
"section_break_11",
|
||||
"content",
|
||||
@@ -160,11 +160,11 @@
|
||||
],
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2026-02-20 13:49:25.599827",
|
||||
"modified_by": "Administrator",
|
||||
"modified": "2026-04-01 12:21:25.050340",
|
||||
"modified_by": "sayali@frappe.io",
|
||||
"module": "LMS",
|
||||
"name": "Course Lesson",
|
||||
"naming_rule": "Expression",
|
||||
"naming_rule": "Expression (old style)",
|
||||
"owner": "Administrator",
|
||||
"permissions": [
|
||||
{
|
||||
|
||||
@@ -224,9 +224,7 @@ def update_course_statistics():
|
||||
|
||||
for course in courses:
|
||||
lessons = get_lesson_count(course.name)
|
||||
|
||||
enrollments = frappe.db.count("LMS Enrollment", {"course": course.name, "member_type": "Student"})
|
||||
|
||||
avg_rating = get_average_rating(course.name) or 0
|
||||
avg_rating = flt(avg_rating, frappe.get_system_settings("float_precision") or 3)
|
||||
|
||||
|
||||
@@ -21,10 +21,6 @@
|
||||
"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",
|
||||
@@ -38,8 +34,7 @@
|
||||
"fieldtype": "Data",
|
||||
"in_list_view": 1,
|
||||
"label": "Title",
|
||||
"reqd": 1,
|
||||
"unique": 1
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "questions",
|
||||
@@ -139,8 +134,7 @@
|
||||
{
|
||||
"fieldname": "duration",
|
||||
"fieldtype": "Data",
|
||||
"label": "Duration (in minutes)",
|
||||
"mandatory_depends_on": "enable_proctoring"
|
||||
"label": "Duration (in minutes)"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
@@ -154,28 +148,6 @@
|
||||
"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,
|
||||
@@ -186,7 +158,7 @@
|
||||
"link_fieldname": "quiz"
|
||||
}
|
||||
],
|
||||
"modified": "2026-03-25 16:23:25.258120",
|
||||
"modified": "2026-04-01 16:56:28.727089",
|
||||
"modified_by": "sayali@frappe.io",
|
||||
"module": "LMS",
|
||||
"name": "LMS Quiz",
|
||||
|
||||
@@ -549,7 +549,6 @@ def get_lesson_count(course: str) -> int:
|
||||
chapters = frappe.get_all("Chapter Reference", {"parent": course}, ["chapter"])
|
||||
for chapter in chapters:
|
||||
lesson_count += frappe.db.count("Lesson Reference", {"parent": chapter.chapter})
|
||||
|
||||
return lesson_count
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user