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}
|
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 ───────────────────────────────────────────────────────
|
# ── Enrollment ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
@router.get("/{course_id}/enrollees")
|
@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 ────────────────────────────────────────────
|
# ── AI content generation ────────────────────────────────────────────
|
||||||
|
|
||||||
@router.post("/{course_id}/ai-generate")
|
@router.post("/{course_id}/ai-generate")
|
||||||
|
|
|
||||||
|
|
@ -656,3 +656,178 @@ def upload_questions_csv(
|
||||||
"errors": errors[:20], # cap error list
|
"errors": errors[:20], # cap error list
|
||||||
"total_rows": len(rows),
|
"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
|
openpyxl==3.1.2
|
||||||
authlib==1.3.0
|
authlib==1.3.0
|
||||||
itsdangerous==2.1.2
|
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="progress-bar" style={{ height: 6 }}>
|
||||||
<div className="fill" style={{ width: `${progressPct}%`, transition: 'width 0.3s' }} />
|
<div className="fill" style={{ width: `${progressPct}%`, transition: 'width 0.3s' }} />
|
||||||
</div>
|
</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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -460,8 +473,18 @@ export default function CourseDetailPage() {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!loadingLesson && activeLesson.lesson_type === 'scorm_package' && (
|
{!loadingLesson && activeLesson.lesson_type === 'scorm_package' && (
|
||||||
<div style={{ textAlign: 'center', padding: 20 }}>
|
<div>
|
||||||
<p style={{ color: 'var(--text-muted)' }}>SCORM package viewer is not yet available.</p>
|
{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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ const LESSON_TYPES = [
|
||||||
{ value: 'document', label: 'Document', icon: '📄' },
|
{ value: 'document', label: 'Document', icon: '📄' },
|
||||||
{ value: 'quiz', label: 'Quiz', icon: '✏️' },
|
{ value: 'quiz', label: 'Quiz', icon: '✏️' },
|
||||||
{ value: 'live_session', label: 'Live Session', 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 || '📝'
|
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,
|
is_required: isRequired ? 1 : 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
// If there's a file upload (document or video), use FormData
|
// Attach SCORM file for separate upload after lesson creation
|
||||||
if (file && (type === 'document' || type === 'video')) {
|
if (file && type === 'scorm_package') {
|
||||||
|
payload._scormFile = file
|
||||||
|
onSave(payload, false)
|
||||||
|
} else if (file && (type === 'document' || type === 'video')) {
|
||||||
const fd = new FormData()
|
const fd = new FormData()
|
||||||
Object.entries(payload).forEach(([k, v]) => {
|
Object.entries(payload).forEach(([k, v]) => {
|
||||||
if (v !== null && v !== undefined) fd.append(k, v)
|
if (v !== null && v !== undefined) fd.append(k, v)
|
||||||
|
|
@ -587,6 +591,36 @@ function LessonForm({ lesson, onSave, onCancel, courseId }) {
|
||||||
</div>
|
</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 */}
|
{/* Live Session */}
|
||||||
{type === 'live_session' && (
|
{type === 'live_session' && (
|
||||||
<>
|
<>
|
||||||
|
|
@ -773,13 +807,25 @@ export default function CourseEditorPage() {
|
||||||
const saveLesson = async (moduleId, lessonId, payload, isFormData) => {
|
const saveLesson = async (moduleId, lessonId, payload, isFormData) => {
|
||||||
clearMessages()
|
clearMessages()
|
||||||
try {
|
try {
|
||||||
|
let savedLessonId = lessonId
|
||||||
|
// Extract SCORM file before saving lesson metadata
|
||||||
|
const scormFile = payload._scormFile
|
||||||
|
if (scormFile) delete payload._scormFile
|
||||||
|
|
||||||
if (lessonId) {
|
if (lessonId) {
|
||||||
// Update existing lesson
|
|
||||||
await api.put(`/courses/${courseId}/lessons/${lessonId}`, payload)
|
await api.put(`/courses/${courseId}/lessons/${lessonId}`, payload)
|
||||||
} else {
|
} else {
|
||||||
// Create new lesson in module
|
const res = await api.post(`/courses/${courseId}/modules/${moduleId}/lessons`, payload)
|
||||||
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)
|
setEditingLessonId(null)
|
||||||
setShowLessonForm(null)
|
setShowLessonForm(null)
|
||||||
fetchCourse()
|
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={() => 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={() => { 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>}
|
{isModerator && <button className="btn btn-secondary btn-sm" onClick={() => setShowCatForm(v => !v)}>+ Category</button>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue