Add certificate generation, SCORM support, QTI export/import
- Certificate: PDF generated on course completion, download from course page - SCORM: upload ZIP packages as lesson type, served in iframe, manifest parsed - QTI 2.1: export selected/all questions as XML, import QTI files into bank - Uses fpdf2 for certificate PDF generation Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
029d985149
commit
db98c6ae69
7 changed files with 505 additions and 8 deletions
|
|
@ -953,6 +953,110 @@ async def upload_lesson_file(
|
|||
return {"local_file_path": lesson.local_file_path}
|
||||
|
||||
|
||||
# ── SCORM ────────────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/{course_id}/lessons/{lesson_id}/scorm")
|
||||
async def upload_scorm_package(
|
||||
course_id: int,
|
||||
lesson_id: int,
|
||||
file: UploadFile = File(...),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Upload a SCORM package (ZIP) for a lesson."""
|
||||
import zipfile
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
_course_owner_or_admin(course_id, current_user, db)
|
||||
lesson = (
|
||||
db.query(CourseLesson)
|
||||
.join(CourseModule, CourseLesson.module_id == CourseModule.id)
|
||||
.filter(CourseLesson.id == lesson_id, CourseModule.course_id == course_id)
|
||||
.first()
|
||||
)
|
||||
if not lesson:
|
||||
raise HTTPException(status_code=404, detail="Lesson not found")
|
||||
|
||||
if not file.filename or not file.filename.lower().endswith(".zip"):
|
||||
raise HTTPException(status_code=400, detail="SCORM package must be a ZIP file")
|
||||
|
||||
contents = await file.read()
|
||||
if len(contents) > 200 * 1024 * 1024: # 200MB limit for SCORM
|
||||
raise HTTPException(status_code=413, detail="SCORM package too large (max 200MB)")
|
||||
|
||||
# Extract to scorm directory
|
||||
scorm_dir = os.path.join(settings.UPLOAD_DIR, "scorm", str(course_id), str(lesson_id))
|
||||
if os.path.exists(scorm_dir):
|
||||
import shutil
|
||||
shutil.rmtree(scorm_dir)
|
||||
os.makedirs(scorm_dir, exist_ok=True)
|
||||
|
||||
import io
|
||||
try:
|
||||
with zipfile.ZipFile(io.BytesIO(contents)) as zf:
|
||||
zf.extractall(scorm_dir)
|
||||
except zipfile.BadZipFile:
|
||||
raise HTTPException(status_code=400, detail="Invalid ZIP file")
|
||||
|
||||
# Parse imsmanifest.xml to find launch file
|
||||
manifest_path = os.path.join(scorm_dir, "imsmanifest.xml")
|
||||
if not os.path.exists(manifest_path):
|
||||
import shutil
|
||||
shutil.rmtree(scorm_dir)
|
||||
raise HTTPException(status_code=400, detail="Not a valid SCORM package — missing imsmanifest.xml")
|
||||
|
||||
launch_file = None
|
||||
try:
|
||||
tree = ET.parse(manifest_path)
|
||||
root = tree.getroot()
|
||||
ns = {"": root.tag.split("}")[0] + "}"} if "}" in root.tag else {}
|
||||
prefix = ns.get("", "")
|
||||
# Find first resource with href
|
||||
for resource in root.iter(f"{prefix}resource"):
|
||||
href = resource.get("href")
|
||||
if href:
|
||||
launch_file = href
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not launch_file:
|
||||
launch_file = "index.html" # fallback
|
||||
|
||||
scorm_url = f"/uploads/scorm/{course_id}/{lesson_id}"
|
||||
lesson.local_file_path = f"{scorm_url}/{launch_file}"
|
||||
lesson.lesson_type = "scorm_package"
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"scorm_url": scorm_url,
|
||||
"launch_file": launch_file,
|
||||
"full_url": f"{scorm_url}/{launch_file}",
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{course_id}/lessons/{lesson_id}/scorm/status")
|
||||
def get_scorm_status(
|
||||
course_id: int,
|
||||
lesson_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Get SCORM completion status for this user/lesson."""
|
||||
enrollment = db.query(CourseEnrollment).filter(
|
||||
CourseEnrollment.course_id == course_id,
|
||||
CourseEnrollment.user_id == current_user.id,
|
||||
).first()
|
||||
if not enrollment:
|
||||
return {"completed": False}
|
||||
progress = (
|
||||
db.query(CourseLessonProgress)
|
||||
.filter(CourseLessonProgress.enrollment_id == enrollment.id, CourseLessonProgress.lesson_id == lesson_id)
|
||||
.first()
|
||||
)
|
||||
return {"completed": progress.status == "completed" if progress else False}
|
||||
|
||||
|
||||
# ── Enrollment ───────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/{course_id}/enrollees")
|
||||
|
|
@ -1273,6 +1377,46 @@ def update_progress(
|
|||
}
|
||||
|
||||
|
||||
# ── Certificate ──────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/{course_id}/certificate")
|
||||
def download_certificate(
|
||||
course_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Generate and return a completion certificate PDF."""
|
||||
from starlette.responses import FileResponse
|
||||
|
||||
enrollment = db.query(CourseEnrollment).filter(
|
||||
CourseEnrollment.course_id == course_id,
|
||||
CourseEnrollment.user_id == current_user.id,
|
||||
).first()
|
||||
if not enrollment:
|
||||
raise HTTPException(status_code=404, detail="Not enrolled in this course")
|
||||
if not enrollment.completed_at:
|
||||
raise HTTPException(status_code=400, detail="Course not yet completed")
|
||||
|
||||
course = db.query(Course).filter(Course.id == course_id).first()
|
||||
if not course:
|
||||
raise HTTPException(status_code=404, detail="Course not found")
|
||||
|
||||
from app.services.certificate_service import generate_certificate
|
||||
cert_path = generate_certificate(
|
||||
student_name=current_user.name or current_user.email,
|
||||
course_title=course.title,
|
||||
completed_at=enrollment.completed_at,
|
||||
upload_dir=settings.UPLOAD_DIR,
|
||||
)
|
||||
|
||||
full_path = os.path.join(settings.UPLOAD_DIR, "certificates", os.path.basename(cert_path))
|
||||
return FileResponse(
|
||||
full_path,
|
||||
media_type="application/pdf",
|
||||
filename=f"Certificate - {course.title}.pdf",
|
||||
)
|
||||
|
||||
|
||||
# ── AI content generation ────────────────────────────────────────────
|
||||
|
||||
@router.post("/{course_id}/ai-generate")
|
||||
|
|
|
|||
|
|
@ -656,3 +656,178 @@ def upload_questions_csv(
|
|||
"errors": errors[:20], # cap error list
|
||||
"total_rows": len(rows),
|
||||
}
|
||||
|
||||
|
||||
# ── QTI Export / Import ──────────────────────────────────────────────
|
||||
|
||||
def _escape_xml(text: str) -> str:
|
||||
"""Escape text for XML content."""
|
||||
if not text:
|
||||
return ""
|
||||
return (text.replace("&", "&").replace("<", "<")
|
||||
.replace(">", ">").replace('"', """).replace("'", "'"))
|
||||
|
||||
|
||||
@router.get("/export/qti")
|
||||
def export_qti(
|
||||
question_ids: str = Query(None, description="Comma-separated question IDs (omit for all shared)"),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Export questions as QTI 2.1 XML."""
|
||||
if question_ids:
|
||||
ids = [int(x.strip()) for x in question_ids.split(",") if x.strip().isdigit()]
|
||||
questions = db.query(Question).filter(Question.id.in_(ids)).all()
|
||||
else:
|
||||
questions = db.query(Question).filter(
|
||||
or_(Question.is_shared == 1, Question.is_shared.is_(None), Question.user_id == current_user.id)
|
||||
).limit(500).all()
|
||||
|
||||
items_xml = []
|
||||
for q in questions:
|
||||
options = q.options or []
|
||||
correct = q.correct_answer or ""
|
||||
correct_id = None
|
||||
|
||||
choices_xml = []
|
||||
for i, opt in enumerate(options):
|
||||
choice_id = f"choice_{i}"
|
||||
if opt.strip().lower() == correct.strip().lower():
|
||||
correct_id = choice_id
|
||||
choices_xml.append(
|
||||
f' <simpleChoice identifier="{choice_id}">{_escape_xml(opt)}</simpleChoice>'
|
||||
)
|
||||
|
||||
if not correct_id:
|
||||
correct_id = "choice_0"
|
||||
|
||||
explanation_xml = ""
|
||||
if q.explanation:
|
||||
explanation_xml = f"""
|
||||
<modalFeedback outcomeIdentifier="FEEDBACK" showHide="show">
|
||||
{_escape_xml(q.explanation)}
|
||||
</modalFeedback>"""
|
||||
|
||||
item = f""" <assessmentItem identifier="q_{q.id}" title="{_escape_xml(q.question_text[:80])}" adaptive="false" timeDependent="false">
|
||||
<responseDeclaration identifier="RESPONSE" cardinality="single" baseType="identifier">
|
||||
<correctResponse>
|
||||
<value>{correct_id}</value>
|
||||
</correctResponse>
|
||||
</responseDeclaration>
|
||||
<outcomeDeclaration identifier="SCORE" cardinality="single" baseType="float">
|
||||
<defaultValue><value>0</value></defaultValue>
|
||||
</outcomeDeclaration>
|
||||
<itemBody>
|
||||
<choiceInteraction responseIdentifier="RESPONSE" shuffle="false" maxChoices="1">
|
||||
<prompt>{_escape_xml(q.question_text)}</prompt>
|
||||
{chr(10).join(choices_xml)}
|
||||
</choiceInteraction>
|
||||
</itemBody>
|
||||
<responseProcessing template="http://www.imsglobal.org/question/qti_v2p1/rptemplates/match_correct"/>{explanation_xml}
|
||||
</assessmentItem>"""
|
||||
items_xml.append(item)
|
||||
|
||||
xml = f"""<?xml version="1.0" encoding="UTF-8"?>
|
||||
<assessmentTest xmlns="http://www.imsglobal.org/xsd/imsqti_v2p1"
|
||||
identifier="pedshub_export" title="PedsHub Question Export">
|
||||
{chr(10).join(items_xml)}
|
||||
</assessmentTest>"""
|
||||
|
||||
return StreamingResponse(
|
||||
io.BytesIO(xml.encode("utf-8")),
|
||||
media_type="application/xml",
|
||||
headers={"Content-Disposition": "attachment; filename=questions_qti.xml"},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/import/qti")
|
||||
def import_qti(
|
||||
file: UploadFile = File(...),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Import questions from a QTI 2.1 XML file."""
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
if not file.filename or not file.filename.lower().endswith(".xml"):
|
||||
raise HTTPException(status_code=400, detail="Please upload a QTI XML file")
|
||||
|
||||
contents = file.file.read()
|
||||
try:
|
||||
root = ET.fromstring(contents)
|
||||
except ET.ParseError:
|
||||
raise HTTPException(status_code=400, detail="Invalid XML file")
|
||||
|
||||
# Handle namespaces
|
||||
ns_match = re.match(r"\{(.+?)\}", root.tag)
|
||||
ns = ns_match.group(1) if ns_match else ""
|
||||
prefix = f"{{{ns}}}" if ns else ""
|
||||
|
||||
created = []
|
||||
errors = []
|
||||
|
||||
# Find all assessmentItem elements (could be nested or flat)
|
||||
items = root.findall(f".//{prefix}assessmentItem")
|
||||
if not items:
|
||||
items = root.findall(f".//assessmentItem") # try without namespace
|
||||
|
||||
for idx, item in enumerate(items):
|
||||
try:
|
||||
# Extract prompt/question text
|
||||
prompt_el = item.find(f".//{prefix}prompt")
|
||||
if prompt_el is None:
|
||||
prompt_el = item.find(f".//prompt")
|
||||
question_text = (prompt_el.text or "").strip() if prompt_el is not None else item.get("title", "")
|
||||
|
||||
if not question_text:
|
||||
errors.append(f"Item {idx + 1}: No question text found")
|
||||
continue
|
||||
|
||||
# Extract choices
|
||||
options = []
|
||||
choice_map = {} # identifier -> text
|
||||
for choice in item.findall(f".//{prefix}simpleChoice") or item.findall(f".//simpleChoice"):
|
||||
text = (choice.text or "").strip()
|
||||
identifier = choice.get("identifier", "")
|
||||
options.append(text)
|
||||
choice_map[identifier] = text
|
||||
|
||||
# Extract correct answer
|
||||
correct_answer = ""
|
||||
correct_el = item.find(f".//{prefix}correctResponse/{prefix}value")
|
||||
if correct_el is None:
|
||||
correct_el = item.find(f".//correctResponse/value")
|
||||
if correct_el is not None and correct_el.text:
|
||||
correct_id = correct_el.text.strip()
|
||||
correct_answer = choice_map.get(correct_id, "")
|
||||
|
||||
# Extract explanation from modalFeedback
|
||||
explanation = ""
|
||||
feedback_el = item.find(f".//{prefix}modalFeedback")
|
||||
if feedback_el is None:
|
||||
feedback_el = item.find(f".//modalFeedback")
|
||||
if feedback_el is not None:
|
||||
explanation = (feedback_el.text or "").strip()
|
||||
|
||||
q = Question(
|
||||
question_text=question_text,
|
||||
question_type="multiple_choice",
|
||||
options=options if options else None,
|
||||
correct_answer=correct_answer,
|
||||
explanation=explanation or None,
|
||||
user_id=current_user.id,
|
||||
is_shared=0,
|
||||
)
|
||||
db.add(q)
|
||||
created.append(q)
|
||||
except Exception as e:
|
||||
errors.append(f"Item {idx + 1}: {str(e)[:80]}")
|
||||
|
||||
if created:
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"imported": len(created),
|
||||
"errors": errors[:20],
|
||||
"total_items": len(items),
|
||||
}
|
||||
|
|
|
|||
87
backend/app/services/certificate_service.py
Normal file
87
backend/app/services/certificate_service.py
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
"""Generate PDF completion certificates for courses."""
|
||||
import os
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from fpdf import FPDF
|
||||
|
||||
|
||||
def generate_certificate(
|
||||
student_name: str,
|
||||
course_title: str,
|
||||
completed_at: datetime,
|
||||
upload_dir: str,
|
||||
) -> str:
|
||||
"""Generate a PDF certificate and return the file path relative to uploads."""
|
||||
cert_dir = os.path.join(upload_dir, "certificates")
|
||||
os.makedirs(cert_dir, exist_ok=True)
|
||||
|
||||
filename = f"cert_{uuid.uuid4().hex[:12]}.pdf"
|
||||
filepath = os.path.join(cert_dir, filename)
|
||||
|
||||
pdf = FPDF(orientation="L", unit="mm", format="A4")
|
||||
pdf.set_auto_page_break(auto=False)
|
||||
pdf.add_page()
|
||||
|
||||
w, h = 297, 210 # A4 landscape
|
||||
|
||||
# Border
|
||||
pdf.set_draw_color(26, 86, 219)
|
||||
pdf.set_line_width(2)
|
||||
pdf.rect(10, 10, w - 20, h - 20)
|
||||
pdf.set_line_width(0.5)
|
||||
pdf.rect(14, 14, w - 28, h - 28)
|
||||
|
||||
# Header
|
||||
pdf.set_font("Helvetica", "B", 32)
|
||||
pdf.set_text_color(26, 86, 219)
|
||||
pdf.set_y(35)
|
||||
pdf.cell(w, 12, "Certificate of Completion", align="C", new_x="LMARGIN", new_y="NEXT")
|
||||
|
||||
# Decorative line
|
||||
pdf.set_draw_color(26, 86, 219)
|
||||
pdf.set_line_width(0.8)
|
||||
pdf.line(w / 2 - 60, 52, w / 2 + 60, 52)
|
||||
|
||||
# "This certifies that"
|
||||
pdf.set_font("Helvetica", "", 14)
|
||||
pdf.set_text_color(100, 100, 100)
|
||||
pdf.set_y(62)
|
||||
pdf.cell(w, 8, "This certifies that", align="C", new_x="LMARGIN", new_y="NEXT")
|
||||
|
||||
# Student name
|
||||
pdf.set_font("Helvetica", "B", 28)
|
||||
pdf.set_text_color(30, 30, 30)
|
||||
pdf.set_y(75)
|
||||
pdf.cell(w, 14, student_name, align="C", new_x="LMARGIN", new_y="NEXT")
|
||||
|
||||
# "has successfully completed"
|
||||
pdf.set_font("Helvetica", "", 14)
|
||||
pdf.set_text_color(100, 100, 100)
|
||||
pdf.set_y(96)
|
||||
pdf.cell(w, 8, "has successfully completed the course", align="C", new_x="LMARGIN", new_y="NEXT")
|
||||
|
||||
# Course title
|
||||
pdf.set_font("Helvetica", "BI", 22)
|
||||
pdf.set_text_color(26, 86, 219)
|
||||
pdf.set_y(110)
|
||||
pdf.cell(w, 12, course_title, align="C", new_x="LMARGIN", new_y="NEXT")
|
||||
|
||||
# Date
|
||||
date_str = completed_at.strftime("%B %d, %Y")
|
||||
pdf.set_font("Helvetica", "", 12)
|
||||
pdf.set_text_color(100, 100, 100)
|
||||
pdf.set_y(135)
|
||||
pdf.cell(w, 8, f"Completed on {date_str}", align="C", new_x="LMARGIN", new_y="NEXT")
|
||||
|
||||
# Footer — platform name
|
||||
pdf.set_font("Helvetica", "B", 11)
|
||||
pdf.set_text_color(26, 86, 219)
|
||||
pdf.set_y(160)
|
||||
pdf.cell(w, 8, "PedsHub", align="C", new_x="LMARGIN", new_y="NEXT")
|
||||
pdf.set_font("Helvetica", "", 9)
|
||||
pdf.set_text_color(150, 150, 150)
|
||||
pdf.cell(w, 5, "Pediatric Medical Learning Platform", align="C", new_x="LMARGIN", new_y="NEXT")
|
||||
|
||||
pdf.output(filepath)
|
||||
return f"/uploads/certificates/{filename}"
|
||||
|
|
@ -23,3 +23,4 @@ pgvector==0.3.6
|
|||
openpyxl==3.1.2
|
||||
authlib==1.3.0
|
||||
itsdangerous==2.1.2
|
||||
fpdf2==2.8.1
|
||||
|
|
|
|||
|
|
@ -172,7 +172,20 @@ export default function CourseDetailPage() {
|
|||
<div className="progress-bar" style={{ height: 6 }}>
|
||||
<div className="fill" style={{ width: `${progressPct}%`, transition: 'width 0.3s' }} />
|
||||
</div>
|
||||
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)', marginTop: 4 }}>{progressPct}% complete</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 4 }}>
|
||||
<span style={{ fontSize: '0.75rem', color: 'var(--text-muted)' }}>{progressPct}% complete</span>
|
||||
{course.my_enrollment?.completed_at && (
|
||||
<button className="btn btn-primary btn-sm" onClick={async () => {
|
||||
try {
|
||||
const res = await api.get(`/courses/${courseId}/certificate`, { responseType: 'blob' })
|
||||
const url = URL.createObjectURL(res.data)
|
||||
const a = document.createElement('a')
|
||||
a.href = url; a.download = `Certificate - ${course.title}.pdf`; a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
} catch { }
|
||||
}}>Download Certificate</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -460,8 +473,18 @@ export default function CourseDetailPage() {
|
|||
)}
|
||||
|
||||
{!loadingLesson && activeLesson.lesson_type === 'scorm_package' && (
|
||||
<div style={{ textAlign: 'center', padding: 20 }}>
|
||||
<p style={{ color: 'var(--text-muted)' }}>SCORM package viewer is not yet available.</p>
|
||||
<div>
|
||||
{activeLesson.local_file_path ? (
|
||||
<iframe
|
||||
src={activeLesson.local_file_path}
|
||||
style={{ width: '100%', height: 600, border: '1px solid var(--border)', borderRadius: 8 }}
|
||||
title={activeLesson.title}
|
||||
allow="fullscreen"
|
||||
sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
|
||||
/>
|
||||
) : (
|
||||
<p style={{ color: 'var(--text-muted)', textAlign: 'center', padding: 20 }}>No SCORM package uploaded.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ const LESSON_TYPES = [
|
|||
{ value: 'document', label: 'Document', icon: '📄' },
|
||||
{ value: 'quiz', label: 'Quiz', icon: '✏️' },
|
||||
{ value: 'live_session', label: 'Live Session', icon: '📡' },
|
||||
{ value: 'scorm_package', label: 'SCORM Package', icon: '📦' },
|
||||
]
|
||||
|
||||
const typeIcon = (type) => LESSON_TYPES.find(t => t.value === type)?.icon || '📝'
|
||||
|
|
@ -403,8 +404,11 @@ function LessonForm({ lesson, onSave, onCancel, courseId }) {
|
|||
is_required: isRequired ? 1 : 0,
|
||||
}
|
||||
|
||||
// If there's a file upload (document or video), use FormData
|
||||
if (file && (type === 'document' || type === 'video')) {
|
||||
// Attach SCORM file for separate upload after lesson creation
|
||||
if (file && type === 'scorm_package') {
|
||||
payload._scormFile = file
|
||||
onSave(payload, false)
|
||||
} else if (file && (type === 'document' || type === 'video')) {
|
||||
const fd = new FormData()
|
||||
Object.entries(payload).forEach(([k, v]) => {
|
||||
if (v !== null && v !== undefined) fd.append(k, v)
|
||||
|
|
@ -587,6 +591,36 @@ function LessonForm({ lesson, onSave, onCancel, courseId }) {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* SCORM Package */}
|
||||
{type === 'scorm_package' && (
|
||||
<div className="form-group">
|
||||
<label>SCORM Package (ZIP)</label>
|
||||
{lesson?.local_file_path ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
|
||||
<span style={{ fontSize: '0.85rem', color: 'var(--correct-fg)' }}>Package uploaded</span>
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => {
|
||||
const input = document.createElement('input')
|
||||
input.type = 'file'; input.accept = '.zip'
|
||||
input.onchange = async (e) => {
|
||||
const f = e.target.files[0]; if (!f) return
|
||||
const fd = new FormData(); fd.append('file', f)
|
||||
try {
|
||||
await api.post(`/courses/${courseId}/lessons/${lesson.id}/scorm`, fd)
|
||||
onSave({}) // refresh
|
||||
} catch (err) { setError(err.response?.data?.detail || 'Upload failed') }
|
||||
}
|
||||
input.click()
|
||||
}}>Replace</button>
|
||||
</div>
|
||||
) : (
|
||||
<input type="file" accept=".zip" onChange={e => setFile(e.target.files[0])} />
|
||||
)}
|
||||
<p style={{ fontSize: '0.78rem', color: 'var(--text-muted)', marginTop: 4 }}>
|
||||
Upload a SCORM 1.2 or 2004 package (ZIP with imsmanifest.xml)
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Live Session */}
|
||||
{type === 'live_session' && (
|
||||
<>
|
||||
|
|
@ -773,13 +807,25 @@ export default function CourseEditorPage() {
|
|||
const saveLesson = async (moduleId, lessonId, payload, isFormData) => {
|
||||
clearMessages()
|
||||
try {
|
||||
let savedLessonId = lessonId
|
||||
// Extract SCORM file before saving lesson metadata
|
||||
const scormFile = payload._scormFile
|
||||
if (scormFile) delete payload._scormFile
|
||||
|
||||
if (lessonId) {
|
||||
// Update existing lesson
|
||||
await api.put(`/courses/${courseId}/lessons/${lessonId}`, payload)
|
||||
} else {
|
||||
// Create new lesson in module
|
||||
await api.post(`/courses/${courseId}/modules/${moduleId}/lessons`, payload)
|
||||
const res = await api.post(`/courses/${courseId}/modules/${moduleId}/lessons`, payload)
|
||||
savedLessonId = res.data.id
|
||||
}
|
||||
|
||||
// Upload SCORM package if present
|
||||
if (scormFile && savedLessonId) {
|
||||
const fd = new FormData()
|
||||
fd.append('file', scormFile)
|
||||
await api.post(`/courses/${courseId}/lessons/${savedLessonId}/scorm`, fd)
|
||||
}
|
||||
|
||||
setEditingLessonId(null)
|
||||
setShowLessonForm(null)
|
||||
fetchCourse()
|
||||
|
|
|
|||
|
|
@ -808,6 +808,27 @@ export default function QuestionBankPage() {
|
|||
)}
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => setShowCreateQuestion(true)}>+ Question</button>
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => { setShowImport(true); setImportFile(null); setImportResult(null) }}>Import CSV/Excel</button>
|
||||
<button className="btn btn-secondary btn-sm" onClick={async () => {
|
||||
const input = document.createElement('input'); input.type = 'file'; input.accept = '.xml'
|
||||
input.onchange = async (e) => {
|
||||
const f = e.target.files[0]; if (!f) return
|
||||
const fd = new FormData(); fd.append('file', f)
|
||||
try {
|
||||
const res = await api.post('/questions/import/qti', fd)
|
||||
alert(`Imported ${res.data.imported} of ${res.data.total_items} questions${res.data.errors?.length ? `. ${res.data.errors.length} error(s).` : ''}`)
|
||||
loadQuestions()
|
||||
} catch (err) { alert(err.response?.data?.detail || 'QTI import failed') }
|
||||
}; input.click()
|
||||
}}>Import QTI</button>
|
||||
<button className="btn btn-secondary btn-sm" onClick={async () => {
|
||||
try {
|
||||
const ids = selectedIds.size > 0 ? [...selectedIds].join(',') : ''
|
||||
const res = await api.get(`/questions/export/qti${ids ? `?question_ids=${ids}` : ''}`, { responseType: 'blob' })
|
||||
const url = URL.createObjectURL(res.data)
|
||||
const a = document.createElement('a'); a.href = url; a.download = 'questions_qti.xml'; a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
} catch { }
|
||||
}}>Export QTI{selectedIds.size > 0 ? ` (${selectedIds.size})` : ''}</button>
|
||||
{isModerator && <button className="btn btn-secondary btn-sm" onClick={() => setShowCatForm(v => !v)}>+ Category</button>}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Reference in a new issue