diff --git a/backend/app/routers/courses.py b/backend/app/routers/courses.py
index bfb5e77..ffbe130 100644
--- a/backend/app/routers/courses.py
+++ b/backend/app/routers/courses.py
@@ -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")
diff --git a/backend/app/routers/questions.py b/backend/app/routers/questions.py
index 40aecc2..6542f4d 100644
--- a/backend/app/routers/questions.py
+++ b/backend/app/routers/questions.py
@@ -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' {_escape_xml(opt)}'
+ )
+
+ if not correct_id:
+ correct_id = "choice_0"
+
+ explanation_xml = ""
+ if q.explanation:
+ explanation_xml = f"""
+
+ {_escape_xml(q.explanation)}
+ """
+
+ item = f"""
+
+
+ {correct_id}
+
+
+
+ 0
+
+
+
+ {_escape_xml(q.question_text)}
+{chr(10).join(choices_xml)}
+
+
+ {explanation_xml}
+ """
+ items_xml.append(item)
+
+ xml = f"""
+
+{chr(10).join(items_xml)}
+"""
+
+ 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),
+ }
diff --git a/backend/app/services/certificate_service.py b/backend/app/services/certificate_service.py
new file mode 100644
index 0000000..8bb7bf5
--- /dev/null
+++ b/backend/app/services/certificate_service.py
@@ -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}"
diff --git a/backend/requirements.txt b/backend/requirements.txt
index f9f5b56..7d2ac08 100644
--- a/backend/requirements.txt
+++ b/backend/requirements.txt
@@ -23,3 +23,4 @@ pgvector==0.3.6
openpyxl==3.1.2
authlib==1.3.0
itsdangerous==2.1.2
+fpdf2==2.8.1
diff --git a/frontend/src/pages/CourseDetailPage.jsx b/frontend/src/pages/CourseDetailPage.jsx
index 59d166c..8c42e2d 100644
--- a/frontend/src/pages/CourseDetailPage.jsx
+++ b/frontend/src/pages/CourseDetailPage.jsx
@@ -172,7 +172,20 @@ export default function CourseDetailPage() {
- {progressPct}% complete
+
+ {progressPct}% complete
+ {course.my_enrollment?.completed_at && (
+
+ )}
+
)}
@@ -460,8 +473,18 @@ export default function CourseDetailPage() {
)}
{!loadingLesson && activeLesson.lesson_type === 'scorm_package' && (
-
-
SCORM package viewer is not yet available.
+
+ {activeLesson.local_file_path ? (
+
+ ) : (
+
No SCORM package uploaded.
+ )}
)}
diff --git a/frontend/src/pages/CourseEditorPage.jsx b/frontend/src/pages/CourseEditorPage.jsx
index 964cf89..2083c5a 100644
--- a/frontend/src/pages/CourseEditorPage.jsx
+++ b/frontend/src/pages/CourseEditorPage.jsx
@@ -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 }) {
)}
+ {/* SCORM Package */}
+ {type === 'scorm_package' && (
+
+
+ {lesson?.local_file_path ? (
+
+ Package uploaded
+
+
+ ) : (
+
setFile(e.target.files[0])} />
+ )}
+
+ Upload a SCORM 1.2 or 2004 package (ZIP with imsmanifest.xml)
+
+
+ )}
+
{/* 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()
diff --git a/frontend/src/pages/QuestionBankPage.jsx b/frontend/src/pages/QuestionBankPage.jsx
index 7f5ab0d..9a6cfcf 100644
--- a/frontend/src/pages/QuestionBankPage.jsx
+++ b/frontend/src/pages/QuestionBankPage.jsx
@@ -808,6 +808,27 @@ export default function QuestionBankPage() {
)}
+
+
{isModerator && }