"""Course management — CRUD, modules, lessons, enrollment, progress, AI content, BBB.""" import hashlib import logging import os import uuid from datetime import datetime from urllib.parse import urlencode from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, File from pydantic import BaseModel, Field from sqlalchemy import func from sqlalchemy.orm import Session from app.config import settings from app.database import get_db from app.models.course import ( Course, CourseModule, CourseLesson, CourseEnrollment, CourseLessonProgress, ) from app.models.question import Question from app.models.quiz import Quiz from app.models.user import User from app.utils.auth import get_current_user logger = logging.getLogger(__name__) router = APIRouter() # ── Schemas ────────────────────────────────────────────────────────── class CourseCreate(BaseModel): title: str = Field(..., min_length=1, max_length=300) description: str | None = Field(None, max_length=5000) class CourseUpdate(BaseModel): title: str | None = Field(None, max_length=300) description: str | None = Field(None, max_length=5000) requires_subscription: int | None = None class ModuleCreate(BaseModel): title: str = Field(..., min_length=1, max_length=300) description: str | None = Field(None, max_length=2000) position: int | None = None class ModuleUpdate(BaseModel): title: str | None = Field(None, max_length=300) description: str | None = Field(None, max_length=2000) position: int | None = None class ModuleReorder(BaseModel): module_ids: list[int] class LessonCreate(BaseModel): title: str = Field(..., min_length=1, max_length=300) lesson_type: str = "video" # video, document, quiz, live content_text: str | None = None video_url: str | None = None quiz_id: int | None = None live_session_url: str | None = None live_session_start: str | None = None live_session_end: str | None = None duration_minutes: int | None = None is_required: int = 1 position: int | None = None class LessonUpdate(BaseModel): title: str | None = Field(None, max_length=300) lesson_type: str | None = None content_text: str | None = None video_url: str | None = None video_provider: str | None = None duration_minutes: int | None = None is_required: int | None = None position: int | None = None quiz_id: int | None = None live_session_url: str | None = None live_session_start: str | None = None live_session_end: str | None = None class ProgressUpdate(BaseModel): status: str | None = None # not_started, in_progress, completed time_spent: int | None = None # seconds score: float | None = None class AIGenerateRequest(BaseModel): model_config = {"protected_namespaces": ()} prompt: str action: str = "generate" # generate, refine, summarize existing_content: str | None = None model_id: str | None = None # optional override model # ── Helpers ────────────────────────────────────────────────────────── def _course_owner_or_admin(course_id: int, current_user: User, db: Session) -> Course: course = db.query(Course).filter(Course.id == course_id).first() if not course: raise HTTPException(status_code=404, detail="Course not found") if course.user_id != current_user.id and not current_user.is_admin: raise HTTPException(status_code=403, detail="Not your course") return course def _detect_video_provider(url: str | None) -> str | None: if not url: return None lower = url.lower() if "vimeo.com" in lower: return "vimeo" if "youtube.com" in lower or "youtu.be" in lower: return "youtube" return "local" def _bbb_checksum(call_name: str, params: str, secret: str) -> str: return hashlib.sha1(f"{call_name}{params}{secret}".encode()).hexdigest() # ── Course CRUD ────────────────────────────────────────────────────── @router.post("/") def create_course( data: CourseCreate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): """Create a new course (moderators and admins only). Starts as draft.""" if not current_user.is_moderator: raise HTTPException(status_code=403, detail="Only moderators and admins can create courses") course = Course( title=data.title, description=data.description, user_id=current_user.id, status="draft", ) db.add(course) db.commit() db.refresh(course) return { "id": course.id, "title": course.title, "status": course.status, "created_at": course.created_at, } @router.get("/") def list_courses( db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): """List courses. Published visible to all; draft/archived only to creator/admin.""" enrolled_count_sq = ( db.query( CourseEnrollment.course_id, func.count(CourseEnrollment.id).label("enrolled_count"), ) .group_by(CourseEnrollment.course_id) .subquery() ) rows = ( db.query( Course, func.coalesce(enrolled_count_sq.c.enrolled_count, 0).label("enrolled_count"), ) .outerjoin(enrolled_count_sq, Course.id == enrolled_count_sq.c.course_id) .order_by(Course.created_at.desc()) .all() ) # Pre-fetch module counts mod_counts = dict( db.query(CourseModule.course_id, func.count(CourseModule.id)) .group_by(CourseModule.course_id) .all() ) user_cache: dict[int, str] = {} result = [] for course, enrolled in rows: # Filter: non-published courses only visible to creator or admin if course.status != "published": if course.user_id != current_user.id and not current_user.is_admin: continue if course.user_id not in user_cache: owner = db.query(User).filter(User.id == course.user_id).first() user_cache[course.user_id] = owner.name if owner else "Unknown" result.append({ "id": course.id, "title": course.title, "description": course.description, "status": course.status, "thumbnail_path": course.thumbnail_path, "requires_subscription": course.requires_subscription, "user_id": course.user_id, "creator_name": user_cache[course.user_id], "enrolled_count": int(enrolled), "module_count": mod_counts.get(course.id, 0), "created_at": course.created_at, "updated_at": course.updated_at, }) return result @router.get("/published") def list_published_courses( limit: int = Query(20, le=100), offset: int = Query(0), db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): """List only published courses (for students browsing). Paginated.""" enrolled_count_sq = ( db.query( CourseEnrollment.course_id, func.count(CourseEnrollment.id).label("enrolled_count"), ) .group_by(CourseEnrollment.course_id) .subquery() ) total = db.query(Course).filter(Course.status == "published").count() rows = ( db.query( Course, func.coalesce(enrolled_count_sq.c.enrolled_count, 0).label("enrolled_count"), ) .outerjoin(enrolled_count_sq, Course.id == enrolled_count_sq.c.course_id) .filter(Course.status == "published") .order_by(Course.created_at.desc()) .offset(offset) .limit(limit) .all() ) # Pre-fetch module counts course_ids = [c.id for c, _ in rows] mod_counts = dict( db.query(CourseModule.course_id, func.count(CourseModule.id)) .filter(CourseModule.course_id.in_(course_ids)) .group_by(CourseModule.course_id) .all() ) if course_ids else {} # Check which courses current user is enrolled in my_enrolled_ids = set( db.query(CourseEnrollment.course_id) .filter(CourseEnrollment.user_id == current_user.id, CourseEnrollment.course_id.in_(course_ids)) .all() ) my_enrolled_ids = {row[0] for row in my_enrolled_ids} user_cache: dict[int, str] = {} courses = [] for course, enrolled in rows: if course.user_id not in user_cache: owner = db.query(User).filter(User.id == course.user_id).first() user_cache[course.user_id] = owner.name if owner else "Unknown" courses.append({ "id": course.id, "title": course.title, "description": course.description, "thumbnail_path": course.thumbnail_path, "requires_subscription": course.requires_subscription, "user_id": course.user_id, "creator_name": user_cache[course.user_id], "enrolled_count": int(enrolled), "module_count": mod_counts.get(course.id, 0), "is_enrolled": course.id in my_enrolled_ids, "created_at": course.created_at, }) return {"total": total, "courses": courses} @router.get("/my-courses") def my_courses( db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): """List courses the current user is enrolled in, with progress.""" enrollments = ( db.query(CourseEnrollment) .filter(CourseEnrollment.user_id == current_user.id) .all() ) course_ids = [e.course_id for e in enrollments] mod_counts = dict( db.query(CourseModule.course_id, func.count(CourseModule.id)) .filter(CourseModule.course_id.in_(course_ids)) .group_by(CourseModule.course_id) .all() ) if course_ids else {} result = [] for enrollment in enrollments: course = db.query(Course).filter(Course.id == enrollment.course_id).first() if not course: continue owner = db.query(User).filter(User.id == course.user_id).first() result.append({ "id": course.id, "title": course.title, "description": course.description, "thumbnail_path": course.thumbnail_path, "status": course.status, "creator_name": owner.name if owner else "Unknown", "module_count": mod_counts.get(course.id, 0), "enrollment_id": enrollment.id, "progress_pct": enrollment.progress_pct, "enrolled_at": enrollment.enrolled_at, "completed_at": enrollment.completed_at, }) return result def _build_lesson_dict(les, db, user_id): """Build lesson dict with quiz metadata if applicable.""" d = { "id": les.id, "title": les.title, "lesson_type": les.lesson_type, "content_text": les.content_text, "video_url": les.video_url, "video_provider": les.video_provider, "local_file_path": les.local_file_path, "duration_minutes": les.duration_minutes, "quiz_id": les.quiz_id, "live_session_url": les.live_session_url, "live_session_start": les.live_session_start, "live_session_end": les.live_session_end, "description": les.description, "is_required": les.is_required, "position": les.position, "bbb_meeting_id": les.bbb_meeting_id, } # Add quiz info for quiz lessons if les.quiz_id and les.lesson_type == "quiz": from app.models.attempt import QuizAttempt quiz = db.query(Quiz).filter(Quiz.id == les.quiz_id).first() if quiz: attempts = ( db.query(QuizAttempt) .filter(QuizAttempt.quiz_id == quiz.id, QuizAttempt.user_id == user_id) .order_by(QuizAttempt.completed_at.desc()) .all() ) d["quiz_title"] = quiz.title d["quiz_allow_review"] = quiz.allow_review d["quiz_mode"] = quiz.mode d["quiz_time_limit"] = quiz.time_limit_minutes d["quiz_max_attempts"] = quiz.max_attempts d["quiz_questions_count"] = quiz.questions_count d["quiz_questions_per_attempt"] = quiz.questions_per_attempt d["quiz_attempts"] = [ { "id": a.id, "score": a.score, "total": a.total_questions, "percentage": round(a.score / a.total_questions * 100) if a.total_questions else 0, "completed_at": a.completed_at.isoformat() if a.completed_at else None, } for a in attempts ] return d @router.get("/{course_id}") def get_course( course_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): """Full course detail with modules and lessons. Includes enrollment progress if enrolled.""" course = db.query(Course).filter(Course.id == course_id).first() if not course: raise HTTPException(status_code=404, detail="Course not found") # Non-published courses only visible to creator or admin if course.status != "published": if course.user_id != current_user.id and not current_user.is_admin: raise HTTPException(status_code=404, detail="Course not found") # Creator info owner = db.query(User).filter(User.id == course.user_id).first() creator_name = owner.name if owner else "Unknown" # Enrolled count enrolled_count = ( db.query(func.count(CourseEnrollment.id)) .filter(CourseEnrollment.course_id == course_id) .scalar() ) # Modules + lessons modules = ( db.query(CourseModule) .filter(CourseModule.course_id == course_id) .order_by(CourseModule.position, CourseModule.id) .all() ) modules_out = [] for mod in modules: lessons = ( db.query(CourseLesson) .filter(CourseLesson.module_id == mod.id) .order_by(CourseLesson.position, CourseLesson.id) .all() ) modules_out.append({ "id": mod.id, "title": mod.title, "description": mod.description, "position": mod.position, "lessons": [ _build_lesson_dict(les, db, current_user.id) for les in lessons ], }) # My enrollment + progress my_enrollment = None enrollment = ( db.query(CourseEnrollment) .filter( CourseEnrollment.course_id == course_id, CourseEnrollment.user_id == current_user.id, ) .first() ) if enrollment: lesson_progress = ( db.query(CourseLessonProgress) .filter(CourseLessonProgress.enrollment_id == enrollment.id) .all() ) my_enrollment = { "enrollment_id": enrollment.id, "progress_pct": enrollment.progress_pct, "enrolled_at": enrollment.enrolled_at, "completed_at": enrollment.completed_at, "lesson_progress": { lp.lesson_id: { "status": lp.status, "time_spent": lp.time_spent_seconds, "score": lp.score, "completed_at": lp.completed_at, } for lp in lesson_progress }, } return { "id": course.id, "title": course.title, "description": course.description, "status": course.status, "thumbnail_path": course.thumbnail_path, "requires_subscription": course.requires_subscription, "user_id": course.user_id, "creator_name": creator_name, "enrolled_count": enrolled_count, "created_at": course.created_at, "updated_at": course.updated_at, "modules": modules_out, "my_enrollment": my_enrollment, } @router.put("/{course_id}") def update_course( course_id: int, data: CourseUpdate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): """Update course metadata (creator or admin).""" course = _course_owner_or_admin(course_id, current_user, db) for field, value in data.model_dump(exclude_unset=True).items(): setattr(course, field, value) course.updated_at = datetime.utcnow() db.commit() db.refresh(course) return { "id": course.id, "title": course.title, "description": course.description, "status": course.status, "updated_at": course.updated_at, } @router.delete("/{course_id}", status_code=204) def delete_course( course_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): """Delete a course (creator or admin).""" course = _course_owner_or_admin(course_id, current_user, db) db.delete(course) db.commit() @router.post("/{course_id}/publish") def publish_course( course_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): """Set course status to published (creator or admin).""" course = _course_owner_or_admin(course_id, current_user, db) course.status = "published" course.updated_at = datetime.utcnow() db.commit() return {"id": course.id, "status": course.status} @router.post("/{course_id}/unpublish") def unpublish_course( course_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): """Set course status back to draft (creator or admin).""" course = _course_owner_or_admin(course_id, current_user, db) course.status = "draft" course.updated_at = datetime.utcnow() db.commit() return {"id": course.id, "status": course.status} @router.post("/{course_id}/thumbnail") async def upload_thumbnail( course_id: int, file: UploadFile = File(...), db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): """Upload a thumbnail image for a course (creator or admin).""" course = _course_owner_or_admin(course_id, current_user, db) # Validate content type if not file.content_type or not file.content_type.startswith("image/"): raise HTTPException(status_code=400, detail="File must be an image") upload_dir = os.path.join(settings.UPLOAD_DIR, "course_thumbnails") os.makedirs(upload_dir, exist_ok=True) ext = os.path.splitext(file.filename or "img.jpg")[1] or ".jpg" filename = f"{course_id}_{uuid.uuid4().hex[:8]}{ext}" file_path = os.path.join(upload_dir, filename) contents = await file.read() with open(file_path, "wb") as f: f.write(contents) course.thumbnail_path = f"/uploads/course_thumbnails/{filename}" course.updated_at = datetime.utcnow() db.commit() return {"thumbnail_path": course.thumbnail_path} # ── Module CRUD ────────────────────────────────────────────────────── @router.post("/{course_id}/modules") def create_module( course_id: int, data: ModuleCreate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): """Add a module to a course (creator or admin).""" _course_owner_or_admin(course_id, current_user, db) # Auto position if not provided position = data.position if position is None: max_order = ( db.query(func.max(CourseModule.position)) .filter(CourseModule.course_id == course_id) .scalar() ) position = (max_order or 0) + 1 module = CourseModule( course_id=course_id, title=data.title, description=data.description, position=position, ) db.add(module) db.commit() db.refresh(module) return { "id": module.id, "course_id": module.course_id, "title": module.title, "description": module.description, "position": module.position, } @router.put("/{course_id}/modules/{module_id}") def update_module( course_id: int, module_id: int, data: ModuleUpdate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): """Update a module (creator or admin).""" _course_owner_or_admin(course_id, current_user, db) module = ( db.query(CourseModule) .filter(CourseModule.id == module_id, CourseModule.course_id == course_id) .first() ) if not module: raise HTTPException(status_code=404, detail="Module not found") for field, value in data.model_dump(exclude_unset=True).items(): setattr(module, field, value) db.commit() db.refresh(module) return { "id": module.id, "title": module.title, "description": module.description, "position": module.position, } @router.delete("/{course_id}/modules/{module_id}", status_code=204) def delete_module( course_id: int, module_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): """Delete a module and its lessons (creator or admin).""" _course_owner_or_admin(course_id, current_user, db) module = ( db.query(CourseModule) .filter(CourseModule.id == module_id, CourseModule.course_id == course_id) .first() ) if not module: raise HTTPException(status_code=404, detail="Module not found") db.delete(module) db.commit() @router.put("/{course_id}/modules/reorder") def reorder_modules( course_id: int, data: ModuleReorder, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): """Reorder modules by providing list of module IDs in desired order.""" _course_owner_or_admin(course_id, current_user, db) for idx, mod_id in enumerate(data.module_ids): module = ( db.query(CourseModule) .filter(CourseModule.id == mod_id, CourseModule.course_id == course_id) .first() ) if module: module.position = idx + 1 db.commit() return {"reordered": True, "count": len(data.module_ids)} class LessonReorder(BaseModel): lesson_ids: list[int] @router.put("/{course_id}/modules/{module_id}/lessons/reorder") def reorder_lessons( course_id: int, module_id: int, data: LessonReorder, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): """Reorder lessons within a module by providing list of lesson IDs in desired order.""" _course_owner_or_admin(course_id, current_user, db) for idx, lesson_id in enumerate(data.lesson_ids): lesson = ( db.query(CourseLesson) .filter(CourseLesson.id == lesson_id, CourseLesson.module_id == module_id) .first() ) if lesson: lesson.position = idx + 1 db.commit() return {"reordered": True, "count": len(data.lesson_ids)} # ── Course Quiz Creation ───────────────────────────────────────────── class CourseQuizCreate(BaseModel): question_ids: list[int] title: str | None = None mode: str = "learning" # timed or learning time_limit_minutes: int | None = None max_attempts: int | None = None # null = unlimited questions_per_attempt: int | None = None # null = all questions; set to pick random subset allow_review: bool = True # whether students can review answers after submit @router.post("/{course_id}/quiz") def create_course_quiz( course_id: int, data: CourseQuizCreate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): """Create a course-only quiz by copying selected questions. The quiz and copies are independent from the main question bank and quiz list.""" _course_owner_or_admin(course_id, current_user, db) if not data.question_ids: raise HTTPException(status_code=400, detail="Select at least one question") source_questions = db.query(Question).filter(Question.id.in_(data.question_ids)).all() if not source_questions: raise HTTPException(status_code=404, detail="No valid questions found") # Preserve caller order id_order = {qid: i for i, qid in enumerate(data.question_ids)} source_questions.sort(key=lambda q: id_order.get(q.id, 999)) # Create quiz tied to this course qpa = data.questions_per_attempt if qpa and qpa > len(source_questions): qpa = None # can't ask for more than the pool quiz = Quiz( user_id=current_user.id, title=data.title or f"Course Quiz ({len(source_questions)} questions)", questions_count=len(source_questions), mode=data.mode if data.mode in ("timed", "learning") else "learning", time_limit_minutes=data.time_limit_minutes, max_attempts=data.max_attempts, questions_per_attempt=qpa, allow_review=1 if data.allow_review else 0, is_published=0, is_shared=0, course_id=course_id, ) db.add(quiz) db.flush() # Copy each question (independent copies) from app.models.quiz_question_link import QuizQuestionLink for pos, sq in enumerate(source_questions): copy = Question( question_text=sq.question_text, question_type=sq.question_type, options=sq.options, correct_answer=sq.correct_answer, explanation=sq.explanation, image_path=sq.image_path, question_category_id=sq.question_category_id, source_quiz_id=quiz.id, user_id=current_user.id, is_shared=0, ) db.add(copy) db.flush() db.add(QuizQuestionLink(quiz_id=quiz.id, question_id=copy.id, position=pos)) quiz.questions_count = len(source_questions) db.commit() db.refresh(quiz) return {"id": quiz.id, "title": quiz.title, "questions_count": quiz.questions_count} # ── Lesson CRUD ────────────────────────────────────────────────────── @router.post("/{course_id}/modules/{module_id}/lessons") def create_lesson( course_id: int, module_id: int, data: LessonCreate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): """Add a lesson to a module (creator or admin). Auto-detects video provider.""" _course_owner_or_admin(course_id, current_user, db) module = ( db.query(CourseModule) .filter(CourseModule.id == module_id, CourseModule.course_id == course_id) .first() ) if not module: raise HTTPException(status_code=404, detail="Module not found") # Auto position if not provided position = data.position if position is None: max_order = ( db.query(func.max(CourseLesson.position)) .filter(CourseLesson.module_id == module_id) .scalar() ) position = (max_order or 0) + 1 video_provider = _detect_video_provider(data.video_url) lesson = CourseLesson( module_id=module_id, title=data.title, lesson_type=data.lesson_type, content_text=data.content_text, video_url=data.video_url, video_provider=video_provider, quiz_id=data.quiz_id, live_session_url=data.live_session_url, live_session_start=data.live_session_start, live_session_end=data.live_session_end, duration_minutes=data.duration_minutes, is_required=data.is_required, position=position, ) db.add(lesson) db.commit() db.refresh(lesson) return { "id": lesson.id, "module_id": lesson.module_id, "title": lesson.title, "lesson_type": lesson.lesson_type, "video_url": lesson.video_url, "video_provider": lesson.video_provider, "position": lesson.position, } @router.put("/{course_id}/lessons/{lesson_id}") def update_lesson( course_id: int, lesson_id: int, data: LessonUpdate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): """Update a lesson (creator or admin).""" _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") updates = data.model_dump(exclude_unset=True) # Re-detect video provider if video_url changed if "video_url" in updates and "video_provider" not in updates: updates["video_provider"] = _detect_video_provider(updates["video_url"]) for field, value in updates.items(): setattr(lesson, field, value) db.commit() db.refresh(lesson) return { "id": lesson.id, "title": lesson.title, "lesson_type": lesson.lesson_type, "video_url": lesson.video_url, "video_provider": lesson.video_provider, "position": lesson.position, } @router.delete("/{course_id}/lessons/{lesson_id}", status_code=204) def delete_lesson( course_id: int, lesson_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): """Delete a lesson (creator or admin).""" _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") db.delete(lesson) db.commit() @router.post("/{course_id}/lessons/{lesson_id}/upload") async def upload_lesson_file( course_id: int, lesson_id: int, file: UploadFile = File(...), db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): """Upload a video or document file for a lesson (creator or admin).""" _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") upload_dir = os.path.join(settings.UPLOAD_DIR, "course_files", str(course_id)) os.makedirs(upload_dir, exist_ok=True) ext = os.path.splitext(file.filename or "file")[1] filename = f"{lesson_id}_{uuid.uuid4().hex[:8]}{ext}" file_path = os.path.join(upload_dir, filename) contents = await file.read() if len(contents) > settings.MAX_UPLOAD_SIZE: raise HTTPException(status_code=413, detail="File too large") with open(file_path, "wb") as f: f.write(contents) lesson.local_file_path = f"/uploads/course_files/{course_id}/{filename}" if not lesson.video_url: lesson.video_provider = "local" db.commit() 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: logger.warning("Failed to parse SCORM manifest for lesson %d", lesson_id, exc_info=True) 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} @router.get("/{course_id}/lessons/{lesson_id}/scorm/data") def get_scorm_data( course_id: int, lesson_id: int, current_user: User = Depends(get_current_user), ): """Get saved SCORM runtime data for this user/lesson.""" import json try: import redis as redis_lib r = redis_lib.from_url(settings.REDIS_URL, decode_responses=True) key = f"scorm_data:{current_user.id}:{course_id}:{lesson_id}" data = r.get(key) return json.loads(data) if data else {} except Exception: logger.warning("Redis unavailable for SCORM data retrieval", exc_info=True) return {} @router.put("/{course_id}/lessons/{lesson_id}/scorm/data") def save_scorm_data( course_id: int, lesson_id: int, data: dict, current_user: User = Depends(get_current_user), ): """Save SCORM runtime data for this user/lesson. Persists bookmarks, scores, and state.""" import json try: import redis as redis_lib r = redis_lib.from_url(settings.REDIS_URL, decode_responses=True) key = f"scorm_data:{current_user.id}:{course_id}:{lesson_id}" r.set(key, json.dumps(data), ex=60 * 60 * 24 * 90) # 90 day expiry return {"saved": True} except Exception: raise HTTPException(status_code=500, detail="Failed to save SCORM data") # ── Enrollment ─────────────────────────────────────────────────────── @router.get("/{course_id}/enrollees") def list_enrollees( course_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): """List all enrollees with progress and quiz scores. Creator or admin only.""" course = _course_owner_or_admin(course_id, current_user, db) enrollments = ( db.query(CourseEnrollment) .filter(CourseEnrollment.course_id == course_id) .all() ) # Get all quiz lessons for this course from app.models.attempt import QuizAttempt quiz_lessons = ( db.query(CourseLesson) .join(CourseModule, CourseLesson.module_id == CourseModule.id) .filter(CourseModule.course_id == course_id, CourseLesson.lesson_type == "quiz", CourseLesson.quiz_id.isnot(None)) .all() ) quiz_ids = [l.quiz_id for l in quiz_lessons] quiz_titles = {} for l in quiz_lessons: q = db.query(Quiz).filter(Quiz.id == l.quiz_id).first() quiz_titles[l.quiz_id] = q.title if q else l.title result = [] for enrollment in enrollments: user = db.query(User).filter(User.id == enrollment.user_id).first() if not user: continue # Lesson progress lesson_progress = ( db.query(CourseLessonProgress) .filter(CourseLessonProgress.enrollment_id == enrollment.id) .all() ) completed_count = sum(1 for lp in lesson_progress if lp.status == "completed") # Quiz scores quiz_scores = [] for qid in quiz_ids: best = ( db.query(QuizAttempt) .filter(QuizAttempt.quiz_id == qid, QuizAttempt.user_id == enrollment.user_id, QuizAttempt.completed_at.isnot(None)) .order_by(QuizAttempt.score.desc()) .first() ) if best: quiz_scores.append({ "quiz_id": qid, "quiz_title": quiz_titles.get(qid, f"Quiz {qid}"), "best_score": best.score, "total": best.total_questions, "percentage": round(best.score / best.total_questions * 100) if best.total_questions else 0, "attempts": db.query(func.count(QuizAttempt.id)).filter( QuizAttempt.quiz_id == qid, QuizAttempt.user_id == enrollment.user_id, QuizAttempt.completed_at.isnot(None) ).scalar(), }) result.append({ "user_id": enrollment.user_id, "name": user.name, "email": user.email, "progress_pct": enrollment.progress_pct or 0, "completed_lessons": completed_count, "enrolled_at": enrollment.enrolled_at, "completed_at": enrollment.completed_at, "quiz_scores": quiz_scores, }) return result @router.get("/{course_id}/enrollees/export") def export_enrollees_csv( course_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): """Export enrollee data as CSV. Creator or admin only.""" import csv as csv_mod import io as io_mod from fastapi.responses import StreamingResponse from app.models.attempt import QuizAttempt course = _course_owner_or_admin(course_id, current_user, db) enrollments = db.query(CourseEnrollment).filter(CourseEnrollment.course_id == course_id).all() quiz_lessons = ( db.query(CourseLesson) .join(CourseModule, CourseLesson.module_id == CourseModule.id) .filter(CourseModule.course_id == course_id, CourseLesson.lesson_type == "quiz", CourseLesson.quiz_id.isnot(None)) .all() ) quiz_ids = [l.quiz_id for l in quiz_lessons] quiz_titles = {} for l in quiz_lessons: q = db.query(Quiz).filter(Quiz.id == l.quiz_id).first() quiz_titles[l.quiz_id] = q.title if q else l.title buf = io_mod.StringIO() writer = csv_mod.writer(buf) # Header header = ["Name", "Email", "Progress %", "Completed Lessons", "Enrolled Date", "Completed Date"] for qid in quiz_ids: header.append(f"Quiz: {quiz_titles.get(qid, qid)} (Best %)") header.append(f"Quiz: {quiz_titles.get(qid, qid)} (Attempts)") writer.writerow(header) for enrollment in enrollments: user = db.query(User).filter(User.id == enrollment.user_id).first() if not user: continue lp_count = db.query(func.count(CourseLessonProgress.id)).filter( CourseLessonProgress.enrollment_id == enrollment.id, CourseLessonProgress.status == "completed" ).scalar() row = [ user.name, user.email, enrollment.progress_pct or 0, lp_count, enrollment.enrolled_at.strftime("%Y-%m-%d") if enrollment.enrolled_at else "", enrollment.completed_at.strftime("%Y-%m-%d") if enrollment.completed_at else "", ] for qid in quiz_ids: best = ( db.query(QuizAttempt) .filter(QuizAttempt.quiz_id == qid, QuizAttempt.user_id == enrollment.user_id, QuizAttempt.completed_at.isnot(None)) .order_by(QuizAttempt.score.desc()) .first() ) if best: row.append(round(best.score / best.total_questions * 100) if best.total_questions else 0) row.append(db.query(func.count(QuizAttempt.id)).filter( QuizAttempt.quiz_id == qid, QuizAttempt.user_id == enrollment.user_id, QuizAttempt.completed_at.isnot(None) ).scalar()) else: row.append("") row.append(0) writer.writerow(row) buf.seek(0) # Add UTF-8 BOM for LibreOffice/Excel compatibility csv_content = "\ufeff" + buf.getvalue() filename = f"{course.title.replace(' ', '_')}_enrollees.csv" return StreamingResponse( iter([csv_content]), media_type="text/csv; charset=utf-8", headers={"Content-Disposition": f"attachment; filename={filename}"}, ) @router.post("/{course_id}/enroll") def enroll( course_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): """Enroll in a published course.""" course = db.query(Course).filter(Course.id == course_id).first() if not course: raise HTTPException(status_code=404, detail="Course not found") if course.status != "published": raise HTTPException(status_code=400, detail="Course is not published") # Subscription gate (placeholder for future Stripe) if course.requires_subscription: raise HTTPException(status_code=402, detail="This course requires a subscription") # No double enrollment existing = ( db.query(CourseEnrollment) .filter( CourseEnrollment.course_id == course_id, CourseEnrollment.user_id == current_user.id, ) .first() ) if existing: raise HTTPException(status_code=409, detail="Already enrolled") enrollment = CourseEnrollment( course_id=course_id, user_id=current_user.id, ) db.add(enrollment) db.commit() db.refresh(enrollment) return { "enrollment_id": enrollment.id, "course_id": course_id, "enrolled_at": enrollment.enrolled_at, } @router.delete("/{course_id}/enroll", status_code=204) def unenroll( course_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): """Unenroll from a course.""" 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") db.delete(enrollment) db.commit() # ── Progress tracking ──────────────────────────────────────────────── @router.put("/{course_id}/lessons/{lesson_id}/progress") def update_progress( course_id: int, lesson_id: int, data: ProgressUpdate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): """Update lesson progress. Auto-calculates enrollment progress_pct.""" enrollment = ( db.query(CourseEnrollment) .filter( CourseEnrollment.course_id == course_id, CourseEnrollment.user_id == current_user.id, ) .first() ) if not enrollment: raise HTTPException(status_code=403, detail="Not enrolled in this course") # Verify lesson belongs to this course 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 in this course") # Upsert lesson progress progress = ( db.query(CourseLessonProgress) .filter( CourseLessonProgress.enrollment_id == enrollment.id, CourseLessonProgress.lesson_id == lesson_id, ) .first() ) if not progress: progress = CourseLessonProgress( enrollment_id=enrollment.id, lesson_id=lesson_id, ) db.add(progress) if data.status is not None: progress.status = data.status if data.status == "completed" and not progress.completed_at: progress.completed_at = datetime.utcnow() if data.time_spent is not None: progress.time_spent_seconds = (progress.time_spent_seconds or 0) + data.time_spent if data.score is not None: progress.score = data.score db.flush() # Recalculate enrollment progress_pct total_required = ( db.query(func.count(CourseLesson.id)) .join(CourseModule, CourseLesson.module_id == CourseModule.id) .filter(CourseModule.course_id == course_id, CourseLesson.is_required == 1) .scalar() ) or 1 completed_required = ( db.query(func.count(CourseLessonProgress.id)) .join(CourseLesson, CourseLessonProgress.lesson_id == CourseLesson.id) .join(CourseModule, CourseLesson.module_id == CourseModule.id) .filter( CourseLessonProgress.enrollment_id == enrollment.id, CourseModule.course_id == course_id, CourseLesson.is_required == 1, CourseLessonProgress.status == "completed", ) .scalar() ) or 0 enrollment.progress_pct = round((completed_required / total_required) * 100, 1) if enrollment.progress_pct >= 100 and not enrollment.completed_at: enrollment.completed_at = datetime.utcnow() db.commit() return { "lesson_id": lesson_id, "status": progress.status, "time_spent": progress.time_spent_seconds, "score": progress.score, "enrollment_progress_pct": enrollment.progress_pct, } # ── 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") def ai_generate_standalone( course_id: int, data: AIGenerateRequest, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): """Generate content using AI without requiring a saved lesson.""" _course_owner_or_admin(course_id, current_user, db) from app.services.ai_service import get_model_for_task, _proxy_model import litellm if data.model_id: model_id = data.model_id # Look up API key for this specific model from app.models.ai_model_config import AIModelConfig config = db.query(AIModelConfig).filter(AIModelConfig.model_id == data.model_id).first() api_key = config.api_key if config else None else: model_id, api_key = get_model_for_task(db, "teach") system_msg = ( "You are a medical education content specialist for pediatrics. " "Generate high-quality lesson content in clean standard markdown. " "Use ## for section headings (never # — the title is separate). " "Use **bold**, *italic*, - bullet lists, 1. numbered lists, > blockquotes, " "and standard markdown tables (| col | col |). " "Include learning objectives, key concepts, tables, and clinical pearls. " "If the user provides existing content, you can see it and modify/extend it as instructed." ) user_msg = data.prompt if data.existing_content: user_msg = f"Existing content:\n{data.existing_content}\n\nInstructions: {data.prompt}" try: kwargs = {"model": _proxy_model(model_id), "messages": [ {"role": "system", "content": system_msg}, {"role": "user", "content": user_msg}, ], "max_tokens": 4000} if api_key: kwargs["api_key"] = api_key elif settings.LITELLM_API_KEY: kwargs["api_key"] = settings.LITELLM_API_KEY if settings.LITELLM_API_BASE: kwargs["api_base"] = settings.LITELLM_API_BASE response = litellm.completion(**kwargs) return {"generated_content": response.choices[0].message.content, "model_used": model_id} except Exception as e: logger.error(f"AI generation failed: {e}") raise HTTPException(status_code=502, detail="AI generation failed") @router.post("/{course_id}/lessons/{lesson_id}/ai-generate") def ai_generate_content( course_id: int, lesson_id: int, data: AIGenerateRequest, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): """Generate or refine lesson text content using AI.""" _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 in this course") from app.services.ai_service import get_model_for_task, _proxy_model import litellm if data.model_id: model_id = data.model_id from app.models.ai_model_config import AIModelConfig config = db.query(AIModelConfig).filter(AIModelConfig.model_id == data.model_id).first() api_key = config.api_key if config else None else: model_id, api_key = get_model_for_task(db, "teach") if data.action == "refine" and data.existing_content: system_msg = ( "You are a medical education content specialist. " "Refine the following lesson content based on the user's instructions. " "Maintain accuracy and a clear, professional tone suitable for medical learners." ) user_msg = ( f"Lesson title: {lesson.title}\n\n" f"Existing content:\n{data.existing_content}\n\n" f"Instructions: {data.prompt}" ) elif data.action == "summarize" and data.existing_content: system_msg = ( "You are a medical education content specialist. " "Summarize the following lesson content concisely while preserving key medical facts." ) user_msg = ( f"Lesson title: {lesson.title}\n\n" f"Content to summarize:\n{data.existing_content}\n\n" f"Additional instructions: {data.prompt}" ) else: system_msg = ( "You are a medical education content specialist for pediatrics. " "Generate high-quality lesson content in clean standard markdown. " "Use ## for section headings (never # — the title is separate). " "Use **bold**, *italic*, - bullet lists, 1. numbered lists, > blockquotes, " "and standard markdown tables (| col | col |). " "Include learning objectives, key concepts, tables, and clinical pearls. " "If the user provides existing content, you can see it and modify/extend it as instructed." ) user_msg = ( f"Lesson title: {lesson.title}\n\n" f"Instructions: {data.prompt}" ) try: kwargs = { "model": _proxy_model(model_id), "messages": [ {"role": "system", "content": system_msg}, {"role": "user", "content": user_msg}, ], "max_tokens": 4000, } if api_key: kwargs["api_key"] = api_key elif settings.LITELLM_API_KEY: kwargs["api_key"] = settings.LITELLM_API_KEY if settings.LITELLM_API_BASE: kwargs["api_base"] = settings.LITELLM_API_BASE response = litellm.completion(**kwargs) generated = response.choices[0].message.content except Exception as e: logger.error(f"AI generation failed: {e}") raise HTTPException(status_code=502, detail="AI generation failed") return { "lesson_id": lesson_id, "generated_content": generated, "model_used": model_id, } # ── BBB integration ────────────────────────────────────────────────── @router.post("/{course_id}/lessons/{lesson_id}/bbb/create") def bbb_create_meeting( course_id: int, lesson_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): """Create a BigBlueButton meeting for a live lesson (creator or admin).""" _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 in this course") bbb_url = getattr(settings, "BBB_SERVER_URL", None) bbb_secret = getattr(settings, "BBB_SECRET", None) if not bbb_url or not bbb_secret: raise HTTPException(status_code=501, detail="BBB not configured") meeting_id = lesson.bbb_meeting_id or f"pedshub-{course_id}-{lesson_id}" params = urlencode({ "name": lesson.title, "meetingID": meeting_id, "attendeePW": "ap", "moderatorPW": "mp", }) checksum = _bbb_checksum("create", params, bbb_secret) create_url = f"{bbb_url.rstrip('/')}/api/create?{params}&checksum={checksum}" # Call BBB API import httpx try: resp = httpx.get(create_url, timeout=10) resp.raise_for_status() except Exception as e: logger.error(f"BBB create failed: {e}") raise HTTPException(status_code=502, detail="Failed to create BBB meeting") lesson.bbb_meeting_id = meeting_id db.commit() return {"meeting_id": meeting_id, "create_url": create_url} @router.get("/{course_id}/lessons/{lesson_id}/bbb/join") def bbb_join_meeting( course_id: int, lesson_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): """Generate a BBB join URL for the current user.""" 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 in this course") if not lesson.bbb_meeting_id: raise HTTPException(status_code=400, detail="No BBB meeting created for this lesson") # Verify user is enrolled or is the course owner/admin course = db.query(Course).filter(Course.id == course_id).first() is_owner = course and (course.user_id == current_user.id or current_user.is_admin) if not is_owner: enrolled = db.query(CourseEnrollment).filter( CourseEnrollment.course_id == course_id, CourseEnrollment.user_id == current_user.id, ).first() if not enrolled: raise HTTPException(status_code=403, detail="You are not enrolled in this course") bbb_url = getattr(settings, "BBB_SERVER_URL", None) bbb_secret = getattr(settings, "BBB_SECRET", None) if not bbb_url or not bbb_secret: raise HTTPException(status_code=501, detail="BBB not configured") # Course owner/admin joins as moderator, others as attendee is_moderator = is_owner password = "mp" if is_moderator else "ap" params = urlencode({ "fullName": current_user.name, "meetingID": lesson.bbb_meeting_id, "password": password, }) checksum = _bbb_checksum("join", params, bbb_secret) join_url = f"{bbb_url.rstrip('/')}/api/join?{params}&checksum={checksum}" return {"join_url": join_url}