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 ? ( +