pdf-quiz-generator/backend/app/routers/courses.py

1709 lines
59 KiB
Python

"""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}