fix: import modal ui

This commit is contained in:
Jannat Patel
2026-04-02 13:52:07 +05:30
parent cd85c5c57f
commit e1e2c08493
3 changed files with 138 additions and 42 deletions
@@ -11,7 +11,7 @@
v-if="!zip" v-if="!zip"
@dragover.prevent @dragover.prevent
@drop.prevent="(e) => uploadFile(e)" @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" class="h-[120px] 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"> <div v-if="!uploading" class="w-4/5 text-center">
<UploadCloud <UploadCloud
@@ -20,8 +20,8 @@
<input <input
ref="fileInput" ref="fileInput"
type="file" type="file"
accept=".zip"
class="hidden" class="hidden"
accept=".zip"
@change="(e) => uploadFile(e)" @change="(e) => uploadFile(e)"
/> />
<div class="leading-5 text-ink-gray-9"> <div class="leading-5 text-ink-gray-9">
@@ -36,7 +36,7 @@
</div> </div>
<div <div
v-else-if="uploading" v-else-if="uploading"
class="w-4/5 lg:w-2/5 bg-surface-white border rounded-md p-2 my-4" class="w-fit bg-surface-white border rounded-md p-2 my-4"
> >
<div class="space-y-2"> <div class="space-y-2">
<div class="font-medium"> <div class="font-medium">
@@ -56,10 +56,10 @@
</div> </div>
<div <div
v-else-if="zip" v-else-if="zip"
class="h-[100px] flex items-center justify-center bg-surface-gray-1 border border-dashed border-outline-gray-3 rounded-md" class="h-[120px] flex items-center justify-center bg-surface-gray-1 border border-dashed border-outline-gray-3 rounded-md"
> >
<div <div
class="w-4/5 lg:w-3/5 bg-surface-white border rounded-md p-2 flex items-center justify-between items-center" class="w-fit bg-surface-white border rounded-md p-2 flex items-center justify-between items-center"
> >
<div class="space-y-2"> <div class="space-y-2">
<div class="font-medium leading-5 text-ink-gray-9"> <div class="font-medium leading-5 text-ink-gray-9">
@@ -89,8 +89,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { Button, call, Dialog, FileUploadHandler, toast } from 'frappe-ui' import { Button, call, Dialog, FileUploadHandler, toast } from 'frappe-ui'
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { Trash, UploadCloud } from 'lucide-vue-next' import { Trash2, UploadCloud } from 'lucide-vue-next'
import { Trash2 } from 'lucide-vue-next' import { useRouter } from 'vue-router'
const fileInput = ref<HTMLInputElement | null>(null) const fileInput = ref<HTMLInputElement | null>(null)
const show = defineModel<boolean>({ required: true, default: false }) const show = defineModel<boolean>({ required: true, default: false })
@@ -99,6 +99,7 @@ const uploaded = ref(0)
const total = ref(0) const total = ref(0)
const uploading = ref(false) const uploading = ref(false)
const uploadingFile = ref<any | null>(null) const uploadingFile = ref<any | null>(null)
const router = useRouter()
const openFileSelector = () => { const openFileSelector = () => {
fileInput.value?.click() fileInput.value?.click()
@@ -146,7 +147,7 @@ const uploadFile = (e: Event) => {
uploader.on('error', (error: any) => { uploader.on('error', (error: any) => {
uploading.value = false uploading.value = false
toast.error(__('File upload failed. Please try again. {0}').format(error)) toast.error(__('File upload failed. Please try again.'))
console.error('File upload error:', error) console.error('File upload error:', error)
}) })
@@ -162,6 +163,11 @@ const uploadFile = (e: Event) => {
}) })
.catch((error: any) => { .catch((error: any) => {
console.error('File upload error:', error) console.error('File upload error:', error)
toast.error(__('File upload failed. Please try again.'))
uploading.value = false
uploadingFile.value = null
uploaded.value = 0
total.value = 0
}) })
} }
@@ -170,10 +176,14 @@ const importZip = () => {
call('lms.lms.api.import_course_as_zip', { call('lms.lms.api.import_course_as_zip', {
zip_file_path: zip.value.file_url, zip_file_path: zip.value.file_url,
}) })
.then(() => { .then((data: any) => {
toast.success('Course imported successfully!') toast.success('Course imported successfully!')
show.value = false show.value = false
deleteFile() deleteFile()
router.push({
name: 'CourseDetail',
params: { courseName: data },
})
}) })
.catch((error: any) => { .catch((error: any) => {
toast.error('Error importing course: ' + error.message) toast.error('Error importing course: ' + error.message)
+1 -1
View File
@@ -2379,4 +2379,4 @@ def export_course_as_zip(course_name: str):
@frappe.whitelist() @frappe.whitelist()
def import_course_as_zip(zip_file_path): def import_course_as_zip(zip_file_path):
frappe.only_for(["Moderator", "Course Creator"]) frappe.only_for(["Moderator", "Course Creator"])
import_course_zip(zip_file_path) return import_course_zip(zip_file_path)
+118 -32
View File
@@ -1,13 +1,16 @@
import json import json
import os import os
import re
import secrets import secrets
import shutil import shutil
import tempfile import tempfile
import zipfile import zipfile
from datetime import date, datetime, timedelta from datetime import date, datetime, timedelta
from urllib.parse import urlparse
import frappe import frappe
from frappe import _ from frappe import _
from frappe.utils import validate_email_address
def export_course_zip(course_name): def export_course_zip(course_name):
@@ -186,39 +189,54 @@ def write_chapters_json(zip_file, chapters):
for chapter in chapters: for chapter in chapters:
chapter_data = chapter.as_dict() chapter_data = chapter.as_dict()
chapter_json = frappe_json_dumps(chapter_data) chapter_json = frappe_json_dumps(chapter_data)
zip_file.writestr(f"chapters/{chapter.name}.json", chapter_json) safe_name = sanitize_filename(chapter.name)
zip_file.writestr(f"chapters/{safe_name}.json", chapter_json)
def write_lessons_json(zip_file, lessons): def write_lessons_json(zip_file, lessons):
for lesson in lessons: for lesson in lessons:
lesson_data = lesson.as_dict() lesson_data = lesson.as_dict()
lesson_json = frappe_json_dumps(lesson_data) lesson_json = frappe_json_dumps(lesson_data)
zip_file.writestr(f"lessons/{lesson.name}.json", lesson_json) safe_name = sanitize_filename(lesson.name)
zip_file.writestr(f"lessons/{safe_name}.json", lesson_json)
def write_assessments_json(zip_file, assessments, questions, test_cases): def write_assessments_json(zip_file, assessments, questions, test_cases):
for question in questions: for question in questions:
question_json = frappe_json_dumps(question) question_json = frappe_json_dumps(question)
zip_file.writestr(f"assessments/questions/{question.name}.json", question_json) safe_name = sanitize_filename(question["name"])
zip_file.writestr(f"assessments/questions/{safe_name}.json", question_json)
for test_case in test_cases: for test_case in test_cases:
test_case_json = frappe_json_dumps(test_case) test_case_json = frappe_json_dumps(test_case)
zip_file.writestr(f"assessments/test_cases/{test_case.name}.json", test_case_json) safe_name = sanitize_filename(test_case["name"])
zip_file.writestr(f"assessments/test_cases/{safe_name}.json", test_case_json)
for assessment in assessments: for assessment in assessments:
assessment_json = frappe_json_dumps(assessment) assessment_json = frappe_json_dumps(assessment)
zip_file.writestr( safe_doctype = sanitize_filename(assessment["doctype"].lower())
f"assessments/{assessment['doctype'].lower()}_{assessment['name']}.json", assessment_json safe_name = sanitize_filename(assessment["name"])
) zip_file.writestr(f"assessments/{safe_doctype}_{safe_name}.json", assessment_json)
def write_assets(zip_file, assets): def write_assets(zip_file, assets):
assets = list(set(assets)) assets = list(set(assets))
for asset in assets: for asset in assets:
try: try:
# Validate asset URL
if not asset or not isinstance(asset, str):
continue
# Check if it's a valid file URL
parsed_url = urlparse(asset)
if parsed_url.scheme and parsed_url.scheme not in ["http", "https"]:
continue
file_doc = frappe.get_doc("File", {"file_url": asset}) file_doc = frappe.get_doc("File", {"file_url": asset})
file_path = os.path.abspath(file_doc.get_full_path()) file_path = os.path.abspath(file_doc.get_full_path())
zip_file.write(file_path, f"assets/{os.path.basename(asset)}")
safe_filename = sanitize_filename(os.path.basename(asset))
zip_file.write(file_path, f"assets/{safe_filename}")
except Exception: except Exception:
frappe.log_error(frappe.get_traceback(), f"Could not add asset: {asset}") frappe.log_error(frappe.get_traceback(), f"Could not add asset: {asset}")
continue continue
@@ -243,11 +261,27 @@ def write_evaluator_json(zip_file, evaluator):
def serve_zip(final_path, zip_filename): def serve_zip(final_path, zip_filename):
with open(final_path, "rb") as f: # Security: Validate file path is within site directory
frappe.local.response.filename = zip_filename site_path = frappe.get_site_path()
frappe.local.response.filecontent = f.read() if not os.path.abspath(final_path).startswith(site_path):
frappe.local.response.type = "download" frappe.throw(_("Invalid file path"))
frappe.local.response.content_type = "application/zip"
# Security: Check if file exists and is readable
if not os.path.exists(final_path) or not os.path.isfile(final_path):
frappe.throw(_("File not found"))
# Security: Sanitize filename for download
safe_filename = sanitize_filename(zip_filename)
try:
with open(final_path, "rb") as f:
frappe.local.response.filename = safe_filename
frappe.local.response.filecontent = f.read()
frappe.local.response.type = "download"
frappe.local.response.content_type = "application/zip"
except Exception as e:
frappe.log_error(f"Error serving ZIP file: {str(e)}")
frappe.throw(_("Error downloading file"))
def schedule_file_deletion(file_path, delay_seconds=600): def schedule_file_deletion(file_path, delay_seconds=600):
@@ -279,6 +313,8 @@ def frappe_json_dumps(data):
def import_course_zip(zip_file_path): def import_course_zip(zip_file_path):
zip_file_path = zip_file_path.lstrip("/") zip_file_path = zip_file_path.lstrip("/")
actual_path = frappe.get_site_path(zip_file_path) actual_path = frappe.get_site_path(zip_file_path)
validate_zip_file(actual_path)
with zipfile.ZipFile(actual_path, "r") as zip_file: with zipfile.ZipFile(actual_path, "r") as zip_file:
course_data = read_json_from_zip(zip_file, "course.json") course_data = read_json_from_zip(zip_file, "course.json")
if not course_data: if not course_data:
@@ -292,6 +328,7 @@ def import_course_zip(zip_file_path):
create_assessment_docs(zip_file) create_assessment_docs(zip_file)
create_lesson_docs(zip_file, course_doc.name, chapter_docs) create_lesson_docs(zip_file, course_doc.name, chapter_docs)
save_course_structure(zip_file, course_doc, chapter_docs) save_course_structure(zip_file, course_doc, chapter_docs)
return course_doc.name
def read_json_from_zip(zip_file, filename): def read_json_from_zip(zip_file, filename):
@@ -313,27 +350,55 @@ def create_user_for_instructors(zip_file):
def create_user(user): def create_user(user):
if not user.get("email") or not validate_email_address(user["email"]):
frappe.throw(f"Invalid email for user creation: {user.get('email')}")
return
email = user["email"].strip().lower()
first_name = frappe.utils.escape_html(user.get("first_name", "").strip()[:50])
last_name = frappe.utils.escape_html(user.get("last_name", "").strip()[:50])
full_name = frappe.utils.escape_html(user.get("full_name", "").strip()[:100])
name_pattern = re.compile(r"^[a-zA-Z0-9\s\-\.]+$")
if first_name and not name_pattern.match(first_name):
first_name = re.sub(r"[^a-zA-Z0-9\s\-\.]", "", first_name)
if last_name and not name_pattern.match(last_name):
last_name = re.sub(r"[^a-zA-Z0-9\s\-\.]", "", last_name)
user_doc = frappe.new_doc("User") user_doc = frappe.new_doc("User")
user_doc.email = user["email"] user_doc.email = email
user_doc.first_name = user["first_name"] if user.get("first_name") else user["full_name"].split()[0] user_doc.first_name = first_name or full_name.split()[0] if full_name else "Imported"
user_doc.last_name = ( user_doc.last_name = last_name or (
user["last_name"] " ".join(full_name.split()[1:]) if len(full_name.split()) > 1 else "User"
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_doc.full_name = full_name or f"{user_doc.first_name} {user_doc.last_name}".strip()
user["full_name"] if user.get("full_name") else f"{user_doc.first_name} {user_doc.last_name}".strip() user_doc.add_roles(["Course Creator"])
) user_doc.send_welcome_email = False
user_doc.user_image = user.get("user_image") user_image = user.get("user_image")
user_doc.insert(ignore_permissions=True) if user_image:
try:
parsed_url = urlparse(user_image)
if parsed_url.scheme in ["http", "https"] and len(user_image) < 500:
user_doc.user_image = user_image
except Exception:
pass
try:
user_doc.insert()
except Exception as e:
frappe.log_error(f"Error creating user {email}: {str(e)}")
def create_evaluator(zip_file): def create_evaluator(zip_file):
evaluator_data = read_json_from_zip(zip_file, "evaluator.json") evaluator_data = read_json_from_zip(zip_file, "evaluator.json")
if not evaluator_data: if not evaluator_data:
return return
# Validate evaluator data
if not evaluator_data.get("evaluator") or not validate_email_address(evaluator_data.get("evaluator", "")):
frappe.log_error(f"Invalid evaluator data: {evaluator_data}")
return
if not frappe.db.exists("User", evaluator_data["evaluator"]): if not frappe.db.exists("User", evaluator_data["evaluator"]):
create_user(evaluator_data) create_user(evaluator_data)
@@ -457,7 +522,7 @@ def replace_assessment_names(zip_file, content):
return json.dumps(content) return json.dumps(content)
def replace_assets(zip_file, content): def replace_assets(content):
content = json.loads(content) content = json.loads(content)
for block in content.get("blocks", []): for block in content.get("blocks", []):
if block.get("type") == "upload": if block.get("type") == "upload":
@@ -471,7 +536,7 @@ def replace_assets(zip_file, content):
def replace_values_in_content(zip_file, content): def replace_values_in_content(zip_file, content):
return replace_assessment_names(zip_file, content) return replace_assessment_names(zip_file, content)
# replace_assets(zip_file, content) # replace_assets(content)
def create_lesson_docs(zip_file, course_name, chapter_docs): def create_lesson_docs(zip_file, course_name, chapter_docs):
@@ -557,14 +622,22 @@ def create_assets(zip_file):
for file in zip_file.namelist(): for file in zip_file.namelist():
if file.startswith("assets/") and not file.endswith("/"): if file.startswith("assets/") and not file.endswith("/"):
try: try:
# Validate file path
if is_unsafe_path(file):
frappe.log_error(f"Unsafe asset path: {file}")
continue
with zip_file.open(file) as f: with zip_file.open(file) as f:
content = f.read() content = f.read()
asset_name = file.split("/")[-1] asset_name = file.split("/")[-1]
if not frappe.db.exists("File", {"file_name": asset_name}): if not frappe.db.exists("File", {"file_name": asset_name}):
asset_doc = frappe.new_doc("File") try:
asset_doc.file_name = asset_name asset_doc = frappe.new_doc("File")
asset_doc.content = content asset_doc.file_name = asset_name
asset_doc.insert(ignore_permissions=True) asset_doc.content = content
asset_doc.insert()
except Exception as e:
frappe.log_error(f"Error creating asset {asset_name}: {str(e)}")
except Exception as e: except Exception as e:
frappe.log_error(f"Error processing asset {file}: {e}") frappe.log_error(f"Error processing asset {file}: {e}")
@@ -605,3 +678,16 @@ def add_chapter_to_course(course_doc, chapter_docs):
def save_course_structure(zip_file, course_doc, chapter_docs): def save_course_structure(zip_file, course_doc, chapter_docs):
add_chapter_to_course(course_doc, chapter_docs) add_chapter_to_course(course_doc, chapter_docs)
add_lessons_to_chapters(zip_file, course_doc.name, chapter_docs) add_lessons_to_chapters(zip_file, course_doc.name, chapter_docs)
def sanitize_filename(filename):
return re.sub(r"[^a-zA-Z0-9_\-\.]", "_", filename)
def validate_zip_file(zip_file_path):
if not os.path.exists(zip_file_path) or not zipfile.is_zipfile(zip_file_path):
frappe.throw(_("Invalid ZIP file"))
def is_unsafe_path(path):
return ".." in path or path.startswith("/") or path.startswith("\\") or re.search(r'[<>:"|?*]', path)